diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
new file mode 100644
index 0000000000..fbaebf786d
--- /dev/null
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -0,0 +1,659 @@
+import api, { getLinks } from '../api';
+
+export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
+export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
+export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL';
+
+export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST';
+export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
+export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL';
+
+export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST';
+export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
+export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL';
+
+export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST';
+export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS';
+export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL';
+
+export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
+export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
+export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL';
+
+export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
+export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
+export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL';
+
+export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
+export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
+export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL';
+
+export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
+export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
+export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL';
+
+export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST';
+export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS';
+export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL';
+
+export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST';
+export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS';
+export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL';
+
+export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST';
+export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS';
+export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL';
+
+export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
+export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
+export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
+
+export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST';
+export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS';
+export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL';
+
+export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST';
+export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS';
+export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL';
+
+export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST';
+export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS';
+export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
+
+export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
+export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
+export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
+
+export function fetchAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchRelationships([id]));
+
+ if (getState().getIn(['accounts', id], null) !== null) {
+ return;
+ }
+
+ dispatch(fetchAccountRequest(id));
+
+ api(getState).get(`/api/v1/accounts/${id}`).then(response => {
+ dispatch(fetchAccountSuccess(response.data));
+ }).catch(error => {
+ dispatch(fetchAccountFail(id, error));
+ });
+ };
+};
+
+export function fetchAccountRequest(id) {
+ return {
+ type: ACCOUNT_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchAccountSuccess(account) {
+ return {
+ type: ACCOUNT_FETCH_SUCCESS,
+ account,
+ };
+};
+
+export function fetchAccountFail(id, error) {
+ return {
+ type: ACCOUNT_FETCH_FAIL,
+ id,
+ error,
+ skipAlert: true,
+ };
+};
+
+export function followAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(followAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => {
+ dispatch(followAccountSuccess(response.data));
+ }).catch(error => {
+ dispatch(followAccountFail(error));
+ });
+ };
+};
+
+export function unfollowAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(unfollowAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => {
+ dispatch(unfollowAccountSuccess(response.data, getState().get('statuses')));
+ }).catch(error => {
+ dispatch(unfollowAccountFail(error));
+ });
+ };
+};
+
+export function followAccountRequest(id) {
+ return {
+ type: ACCOUNT_FOLLOW_REQUEST,
+ id,
+ };
+};
+
+export function followAccountSuccess(relationship) {
+ return {
+ type: ACCOUNT_FOLLOW_SUCCESS,
+ relationship,
+ };
+};
+
+export function followAccountFail(error) {
+ return {
+ type: ACCOUNT_FOLLOW_FAIL,
+ error,
+ };
+};
+
+export function unfollowAccountRequest(id) {
+ return {
+ type: ACCOUNT_UNFOLLOW_REQUEST,
+ id,
+ };
+};
+
+export function unfollowAccountSuccess(relationship, statuses) {
+ return {
+ type: ACCOUNT_UNFOLLOW_SUCCESS,
+ relationship,
+ statuses,
+ };
+};
+
+export function unfollowAccountFail(error) {
+ return {
+ type: ACCOUNT_UNFOLLOW_FAIL,
+ error,
+ };
+};
+
+export function blockAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(blockAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/block`).then(response => {
+ // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+ dispatch(blockAccountSuccess(response.data, getState().get('statuses')));
+ }).catch(error => {
+ dispatch(blockAccountFail(id, error));
+ });
+ };
+};
+
+export function unblockAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(unblockAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => {
+ dispatch(unblockAccountSuccess(response.data));
+ }).catch(error => {
+ dispatch(unblockAccountFail(id, error));
+ });
+ };
+};
+
+export function blockAccountRequest(id) {
+ return {
+ type: ACCOUNT_BLOCK_REQUEST,
+ id,
+ };
+};
+
+export function blockAccountSuccess(relationship, statuses) {
+ return {
+ type: ACCOUNT_BLOCK_SUCCESS,
+ relationship,
+ statuses,
+ };
+};
+
+export function blockAccountFail(error) {
+ return {
+ type: ACCOUNT_BLOCK_FAIL,
+ error,
+ };
+};
+
+export function unblockAccountRequest(id) {
+ return {
+ type: ACCOUNT_UNBLOCK_REQUEST,
+ id,
+ };
+};
+
+export function unblockAccountSuccess(relationship) {
+ return {
+ type: ACCOUNT_UNBLOCK_SUCCESS,
+ relationship,
+ };
+};
+
+export function unblockAccountFail(error) {
+ return {
+ type: ACCOUNT_UNBLOCK_FAIL,
+ error,
+ };
+};
+
+
+export function muteAccount(id, notifications) {
+ return (dispatch, getState) => {
+ dispatch(muteAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
+ // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+ dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
+ }).catch(error => {
+ dispatch(muteAccountFail(id, error));
+ });
+ };
+};
+
+export function unmuteAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(unmuteAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
+ dispatch(unmuteAccountSuccess(response.data));
+ }).catch(error => {
+ dispatch(unmuteAccountFail(id, error));
+ });
+ };
+};
+
+export function muteAccountRequest(id) {
+ return {
+ type: ACCOUNT_MUTE_REQUEST,
+ id,
+ };
+};
+
+export function muteAccountSuccess(relationship, statuses) {
+ return {
+ type: ACCOUNT_MUTE_SUCCESS,
+ relationship,
+ statuses,
+ };
+};
+
+export function muteAccountFail(error) {
+ return {
+ type: ACCOUNT_MUTE_FAIL,
+ error,
+ };
+};
+
+export function unmuteAccountRequest(id) {
+ return {
+ type: ACCOUNT_UNMUTE_REQUEST,
+ id,
+ };
+};
+
+export function unmuteAccountSuccess(relationship) {
+ return {
+ type: ACCOUNT_UNMUTE_SUCCESS,
+ relationship,
+ };
+};
+
+export function unmuteAccountFail(error) {
+ return {
+ type: ACCOUNT_UNMUTE_FAIL,
+ error,
+ };
+};
+
+
+export function fetchFollowers(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchFollowersRequest(id));
+
+ api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(fetchFollowersFail(id, error));
+ });
+ };
+};
+
+export function fetchFollowersRequest(id) {
+ return {
+ type: FOLLOWERS_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchFollowersSuccess(id, accounts, next) {
+ return {
+ type: FOLLOWERS_FETCH_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function fetchFollowersFail(id, error) {
+ return {
+ type: FOLLOWERS_FETCH_FAIL,
+ id,
+ error,
+ };
+};
+
+export function expandFollowers(id) {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'followers', id, 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandFollowersRequest(id));
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(expandFollowersFail(id, error));
+ });
+ };
+};
+
+export function expandFollowersRequest(id) {
+ return {
+ type: FOLLOWERS_EXPAND_REQUEST,
+ id,
+ };
+};
+
+export function expandFollowersSuccess(id, accounts, next) {
+ return {
+ type: FOLLOWERS_EXPAND_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function expandFollowersFail(id, error) {
+ return {
+ type: FOLLOWERS_EXPAND_FAIL,
+ id,
+ error,
+ };
+};
+
+export function fetchFollowing(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchFollowingRequest(id));
+
+ api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(fetchFollowingFail(id, error));
+ });
+ };
+};
+
+export function fetchFollowingRequest(id) {
+ return {
+ type: FOLLOWING_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchFollowingSuccess(id, accounts, next) {
+ return {
+ type: FOLLOWING_FETCH_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function fetchFollowingFail(id, error) {
+ return {
+ type: FOLLOWING_FETCH_FAIL,
+ id,
+ error,
+ };
+};
+
+export function expandFollowing(id) {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'following', id, 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandFollowingRequest(id));
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(expandFollowingFail(id, error));
+ });
+ };
+};
+
+export function expandFollowingRequest(id) {
+ return {
+ type: FOLLOWING_EXPAND_REQUEST,
+ id,
+ };
+};
+
+export function expandFollowingSuccess(id, accounts, next) {
+ return {
+ type: FOLLOWING_EXPAND_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function expandFollowingFail(id, error) {
+ return {
+ type: FOLLOWING_EXPAND_FAIL,
+ id,
+ error,
+ };
+};
+
+export function fetchRelationships(accountIds) {
+ return (dispatch, getState) => {
+ const loadedRelationships = getState().get('relationships');
+ const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
+
+ if (newAccountIds.length === 0) {
+ return;
+ }
+
+ dispatch(fetchRelationshipsRequest(newAccountIds));
+
+ api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
+ dispatch(fetchRelationshipsSuccess(response.data));
+ }).catch(error => {
+ dispatch(fetchRelationshipsFail(error));
+ });
+ };
+};
+
+export function fetchRelationshipsRequest(ids) {
+ return {
+ type: RELATIONSHIPS_FETCH_REQUEST,
+ ids,
+ skipLoading: true,
+ };
+};
+
+export function fetchRelationshipsSuccess(relationships) {
+ return {
+ type: RELATIONSHIPS_FETCH_SUCCESS,
+ relationships,
+ skipLoading: true,
+ };
+};
+
+export function fetchRelationshipsFail(error) {
+ return {
+ type: RELATIONSHIPS_FETCH_FAIL,
+ error,
+ skipLoading: true,
+ };
+};
+
+export function fetchFollowRequests() {
+ return (dispatch, getState) => {
+ dispatch(fetchFollowRequestsRequest());
+
+ api(getState).get('/api/v1/follow_requests').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null));
+ }).catch(error => dispatch(fetchFollowRequestsFail(error)));
+ };
+};
+
+export function fetchFollowRequestsRequest() {
+ return {
+ type: FOLLOW_REQUESTS_FETCH_REQUEST,
+ };
+};
+
+export function fetchFollowRequestsSuccess(accounts, next) {
+ return {
+ type: FOLLOW_REQUESTS_FETCH_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function fetchFollowRequestsFail(error) {
+ return {
+ type: FOLLOW_REQUESTS_FETCH_FAIL,
+ error,
+ };
+};
+
+export function expandFollowRequests() {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'follow_requests', 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandFollowRequestsRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null));
+ }).catch(error => dispatch(expandFollowRequestsFail(error)));
+ };
+};
+
+export function expandFollowRequestsRequest() {
+ return {
+ type: FOLLOW_REQUESTS_EXPAND_REQUEST,
+ };
+};
+
+export function expandFollowRequestsSuccess(accounts, next) {
+ return {
+ type: FOLLOW_REQUESTS_EXPAND_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function expandFollowRequestsFail(error) {
+ return {
+ type: FOLLOW_REQUESTS_EXPAND_FAIL,
+ error,
+ };
+};
+
+export function authorizeFollowRequest(id) {
+ return (dispatch, getState) => {
+ dispatch(authorizeFollowRequestRequest(id));
+
+ api(getState)
+ .post(`/api/v1/follow_requests/${id}/authorize`)
+ .then(() => dispatch(authorizeFollowRequestSuccess(id)))
+ .catch(error => dispatch(authorizeFollowRequestFail(id, error)));
+ };
+};
+
+export function authorizeFollowRequestRequest(id) {
+ return {
+ type: FOLLOW_REQUEST_AUTHORIZE_REQUEST,
+ id,
+ };
+};
+
+export function authorizeFollowRequestSuccess(id) {
+ return {
+ type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+ id,
+ };
+};
+
+export function authorizeFollowRequestFail(id, error) {
+ return {
+ type: FOLLOW_REQUEST_AUTHORIZE_FAIL,
+ id,
+ error,
+ };
+};
+
+
+export function rejectFollowRequest(id) {
+ return (dispatch, getState) => {
+ dispatch(rejectFollowRequestRequest(id));
+
+ api(getState)
+ .post(`/api/v1/follow_requests/${id}/reject`)
+ .then(() => dispatch(rejectFollowRequestSuccess(id)))
+ .catch(error => dispatch(rejectFollowRequestFail(id, error)));
+ };
+};
+
+export function rejectFollowRequestRequest(id) {
+ return {
+ type: FOLLOW_REQUEST_REJECT_REQUEST,
+ id,
+ };
+};
+
+export function rejectFollowRequestSuccess(id) {
+ return {
+ type: FOLLOW_REQUEST_REJECT_SUCCESS,
+ id,
+ };
+};
+
+export function rejectFollowRequestFail(id, error) {
+ return {
+ type: FOLLOW_REQUEST_REJECT_FAIL,
+ id,
+ error,
+ };
+};
diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js
new file mode 100644
index 0000000000..f37fdeeb6a
--- /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 0000000000..553283a71f
--- /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/bundles.js b/app/javascript/mastodon/actions/bundles.js
new file mode 100644
index 0000000000..ecc9c8f7d3
--- /dev/null
+++ b/app/javascript/mastodon/actions/bundles.js
@@ -0,0 +1,25 @@
+export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST';
+export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS';
+export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL';
+
+export function fetchBundleRequest(skipLoading) {
+ return {
+ type: BUNDLE_FETCH_REQUEST,
+ skipLoading,
+ };
+}
+
+export function fetchBundleSuccess(skipLoading) {
+ return {
+ type: BUNDLE_FETCH_SUCCESS,
+ skipLoading,
+ };
+}
+
+export function fetchBundleFail(error, skipLoading) {
+ return {
+ type: BUNDLE_FETCH_FAIL,
+ error,
+ skipLoading,
+ };
+}
diff --git a/app/javascript/mastodon/actions/cards.js b/app/javascript/mastodon/actions/cards.js
new file mode 100644
index 0000000000..baf04833a2
--- /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/columns.js b/app/javascript/mastodon/actions/columns.js
new file mode 100644
index 0000000000..bcb0cdf983
--- /dev/null
+++ b/app/javascript/mastodon/actions/columns.js
@@ -0,0 +1,40 @@
+import { saveSettings } from './settings';
+
+export const COLUMN_ADD = 'COLUMN_ADD';
+export const COLUMN_REMOVE = 'COLUMN_REMOVE';
+export const COLUMN_MOVE = 'COLUMN_MOVE';
+
+export function addColumn(id, params) {
+ return dispatch => {
+ dispatch({
+ type: COLUMN_ADD,
+ id,
+ params,
+ });
+
+ dispatch(saveSettings());
+ };
+};
+
+export function removeColumn(uuid) {
+ return dispatch => {
+ dispatch({
+ type: COLUMN_REMOVE,
+ uuid,
+ });
+
+ dispatch(saveSettings());
+ };
+};
+
+export function moveColumn(uuid, direction) {
+ return dispatch => {
+ dispatch({
+ type: COLUMN_MOVE,
+ uuid,
+ direction,
+ });
+
+ dispatch(saveSettings());
+ };
+};
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
new file mode 100644
index 0000000000..8a35049b32
--- /dev/null
+++ b/app/javascript/mastodon/actions/compose.js
@@ -0,0 +1,376 @@
+import api from '../api';
+import { throttle } from 'lodash';
+import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
+import { useEmoji } from './emojis';
+
+import {
+ updateTimeline,
+ refreshHomeTimeline,
+ refreshCommunityTimeline,
+ refreshPublicTimeline,
+} from './timelines';
+
+export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
+export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
+export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
+export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
+export const COMPOSE_REPLY = 'COMPOSE_REPLY';
+export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
+export const COMPOSE_MENTION = 'COMPOSE_MENTION';
+export const COMPOSE_RESET = 'COMPOSE_RESET';
+export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
+export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
+export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
+export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
+export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
+
+export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
+export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
+export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
+
+export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
+export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
+
+export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
+export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
+export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
+export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
+export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
+export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
+
+export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
+
+export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
+export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
+export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
+
+export function changeCompose(text) {
+ return {
+ type: COMPOSE_CHANGE,
+ text: text,
+ };
+};
+
+export function replyCompose(status, router) {
+ return (dispatch, getState) => {
+ dispatch({
+ type: COMPOSE_REPLY,
+ status: status,
+ });
+
+ if (!getState().getIn(['compose', 'mounted'])) {
+ router.push('/statuses/new');
+ }
+ };
+};
+
+export function cancelReplyCompose() {
+ return {
+ type: COMPOSE_REPLY_CANCEL,
+ };
+};
+
+export function resetCompose() {
+ return {
+ type: COMPOSE_RESET,
+ };
+};
+
+export function mentionCompose(account, router) {
+ return (dispatch, getState) => {
+ dispatch({
+ type: COMPOSE_MENTION,
+ account: account,
+ });
+
+ if (!getState().getIn(['compose', 'mounted'])) {
+ router.push('/statuses/new');
+ }
+ };
+};
+
+export function submitCompose() {
+ return function (dispatch, getState) {
+ const status = getState().getIn(['compose', 'text'], '');
+
+ if (!status || !status.length) {
+ return;
+ }
+
+ dispatch(submitComposeRequest());
+
+ api(getState).post('/api/v1/statuses', {
+ status,
+ in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
+ media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
+ sensitive: getState().getIn(['compose', 'sensitive']),
+ spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
+ visibility: getState().getIn(['compose', 'privacy']),
+ }, {
+ headers: {
+ 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
+ },
+ }).then(function (response) {
+ dispatch(submitComposeSuccess({ ...response.data }));
+
+ // To make the app more responsive, immediately get the status into the columns
+
+ const insertOrRefresh = (timelineId, refreshAction) => {
+ if (getState().getIn(['timelines', timelineId, 'online'])) {
+ dispatch(updateTimeline(timelineId, { ...response.data }));
+ } else if (getState().getIn(['timelines', timelineId, 'loaded'])) {
+ dispatch(refreshAction());
+ }
+ };
+
+ insertOrRefresh('home', refreshHomeTimeline);
+
+ if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
+ insertOrRefresh('community', refreshCommunityTimeline);
+ insertOrRefresh('public', refreshPublicTimeline);
+ }
+ }).catch(function (error) {
+ dispatch(submitComposeFail(error));
+ });
+ };
+};
+
+export function submitComposeRequest() {
+ return {
+ type: COMPOSE_SUBMIT_REQUEST,
+ };
+};
+
+export function submitComposeSuccess(status) {
+ return {
+ type: COMPOSE_SUBMIT_SUCCESS,
+ status: status,
+ };
+};
+
+export function submitComposeFail(error) {
+ return {
+ type: COMPOSE_SUBMIT_FAIL,
+ error: error,
+ };
+};
+
+export function uploadCompose(files) {
+ return function (dispatch, getState) {
+ if (getState().getIn(['compose', 'media_attachments']).size > 3) {
+ return;
+ }
+
+ dispatch(uploadComposeRequest());
+
+ let data = new FormData();
+ data.append('file', files[0]);
+
+ api(getState).post('/api/v1/media', data, {
+ onUploadProgress: function (e) {
+ dispatch(uploadComposeProgress(e.loaded, e.total));
+ },
+ }).then(function (response) {
+ dispatch(uploadComposeSuccess(response.data));
+ }).catch(function (error) {
+ dispatch(uploadComposeFail(error));
+ });
+ };
+};
+
+export function changeUploadCompose(id, description) {
+ return (dispatch, getState) => {
+ dispatch(changeUploadComposeRequest());
+
+ api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
+ dispatch(changeUploadComposeSuccess(response.data));
+ }).catch(error => {
+ dispatch(changeUploadComposeFail(id, error));
+ });
+ };
+};
+
+export function changeUploadComposeRequest() {
+ return {
+ type: COMPOSE_UPLOAD_CHANGE_REQUEST,
+ skipLoading: true,
+ };
+};
+export function changeUploadComposeSuccess(media) {
+ return {
+ type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
+ media: media,
+ skipLoading: true,
+ };
+};
+
+export function changeUploadComposeFail(error) {
+ return {
+ type: COMPOSE_UPLOAD_CHANGE_FAIL,
+ error: error,
+ skipLoading: true,
+ };
+};
+
+export function uploadComposeRequest() {
+ return {
+ type: COMPOSE_UPLOAD_REQUEST,
+ skipLoading: true,
+ };
+};
+
+export function uploadComposeProgress(loaded, total) {
+ return {
+ type: COMPOSE_UPLOAD_PROGRESS,
+ loaded: loaded,
+ total: total,
+ };
+};
+
+export function uploadComposeSuccess(media) {
+ return {
+ type: COMPOSE_UPLOAD_SUCCESS,
+ media: media,
+ skipLoading: true,
+ };
+};
+
+export function uploadComposeFail(error) {
+ return {
+ type: COMPOSE_UPLOAD_FAIL,
+ error: error,
+ skipLoading: true,
+ };
+};
+
+export function undoUploadCompose(media_id) {
+ return {
+ type: COMPOSE_UPLOAD_UNDO,
+ media_id: media_id,
+ };
+};
+
+export function clearComposeSuggestions() {
+ return {
+ type: COMPOSE_SUGGESTIONS_CLEAR,
+ };
+};
+
+const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
+ api(getState).get('/api/v1/accounts/search', {
+ params: {
+ q: token.slice(1),
+ resolve: false,
+ limit: 4,
+ },
+ }).then(response => {
+ dispatch(readyComposeSuggestionsAccounts(token, response.data));
+ });
+}, 200, { leading: true, trailing: true });
+
+const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
+ const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
+ dispatch(readyComposeSuggestionsEmojis(token, results));
+};
+
+export function fetchComposeSuggestions(token) {
+ return (dispatch, getState) => {
+ if (token[0] === ':') {
+ fetchComposeSuggestionsEmojis(dispatch, getState, token);
+ } else {
+ fetchComposeSuggestionsAccounts(dispatch, getState, token);
+ }
+ };
+};
+
+export function readyComposeSuggestionsEmojis(token, emojis) {
+ return {
+ type: COMPOSE_SUGGESTIONS_READY,
+ token,
+ emojis,
+ };
+};
+
+export function readyComposeSuggestionsAccounts(token, accounts) {
+ return {
+ type: COMPOSE_SUGGESTIONS_READY,
+ token,
+ accounts,
+ };
+};
+
+export function selectComposeSuggestion(position, token, suggestion) {
+ return (dispatch, getState) => {
+ let completion, startPosition;
+
+ if (typeof suggestion === 'object' && suggestion.id) {
+ completion = suggestion.native || suggestion.colons;
+ startPosition = position - 1;
+
+ dispatch(useEmoji(suggestion));
+ } else {
+ completion = getState().getIn(['accounts', suggestion, 'acct']);
+ startPosition = position;
+ }
+
+ dispatch({
+ type: COMPOSE_SUGGESTION_SELECT,
+ position: startPosition,
+ token,
+ completion,
+ });
+ };
+};
+
+export function mountCompose() {
+ return {
+ type: COMPOSE_MOUNT,
+ };
+};
+
+export function unmountCompose() {
+ return {
+ type: COMPOSE_UNMOUNT,
+ };
+};
+
+export function changeComposeSensitivity() {
+ return {
+ type: COMPOSE_SENSITIVITY_CHANGE,
+ };
+};
+
+export function changeComposeSpoilerness() {
+ return {
+ type: COMPOSE_SPOILERNESS_CHANGE,
+ };
+};
+
+export function changeComposeSpoilerText(text) {
+ return {
+ type: COMPOSE_SPOILER_TEXT_CHANGE,
+ text,
+ };
+};
+
+export function changeComposeVisibility(value) {
+ return {
+ type: COMPOSE_VISIBILITY_CHANGE,
+ value,
+ };
+};
+
+export function insertEmojiCompose(position, emoji) {
+ return {
+ type: COMPOSE_EMOJI_INSERT,
+ position,
+ emoji,
+ };
+};
+
+export function changeComposing(value) {
+ return {
+ type: COMPOSE_COMPOSING_CHANGE,
+ value,
+ };
+}
diff --git a/app/javascript/mastodon/actions/domain_blocks.js b/app/javascript/mastodon/actions/domain_blocks.js
new file mode 100644
index 0000000000..44363697a9
--- /dev/null
+++ b/app/javascript/mastodon/actions/domain_blocks.js
@@ -0,0 +1,117 @@
+import api, { getLinks } from '../api';
+
+export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST';
+export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS';
+export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL';
+
+export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
+export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS';
+export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL';
+
+export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST';
+export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS';
+export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL';
+
+export function blockDomain(domain, accountId) {
+ return (dispatch, getState) => {
+ dispatch(blockDomainRequest(domain));
+
+ api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
+ dispatch(blockDomainSuccess(domain, accountId));
+ }).catch(err => {
+ dispatch(blockDomainFail(domain, err));
+ });
+ };
+};
+
+export function blockDomainRequest(domain) {
+ return {
+ type: DOMAIN_BLOCK_REQUEST,
+ domain,
+ };
+};
+
+export function blockDomainSuccess(domain, accountId) {
+ return {
+ type: DOMAIN_BLOCK_SUCCESS,
+ domain,
+ accountId,
+ };
+};
+
+export function blockDomainFail(domain, error) {
+ return {
+ type: DOMAIN_BLOCK_FAIL,
+ domain,
+ error,
+ };
+};
+
+export function unblockDomain(domain, accountId) {
+ return (dispatch, getState) => {
+ dispatch(unblockDomainRequest(domain));
+
+ api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => {
+ dispatch(unblockDomainSuccess(domain, accountId));
+ }).catch(err => {
+ dispatch(unblockDomainFail(domain, err));
+ });
+ };
+};
+
+export function unblockDomainRequest(domain) {
+ return {
+ type: DOMAIN_UNBLOCK_REQUEST,
+ domain,
+ };
+};
+
+export function unblockDomainSuccess(domain, accountId) {
+ return {
+ type: DOMAIN_UNBLOCK_SUCCESS,
+ domain,
+ accountId,
+ };
+};
+
+export function unblockDomainFail(domain, error) {
+ return {
+ type: DOMAIN_UNBLOCK_FAIL,
+ domain,
+ error,
+ };
+};
+
+export function fetchDomainBlocks() {
+ return (dispatch, getState) => {
+ dispatch(fetchDomainBlocksRequest());
+
+ api(getState).get().then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null));
+ }).catch(err => {
+ dispatch(fetchDomainBlocksFail(err));
+ });
+ };
+};
+
+export function fetchDomainBlocksRequest() {
+ return {
+ type: DOMAIN_BLOCKS_FETCH_REQUEST,
+ };
+};
+
+export function fetchDomainBlocksSuccess(domains, next) {
+ return {
+ type: DOMAIN_BLOCKS_FETCH_SUCCESS,
+ domains,
+ next,
+ };
+};
+
+export function fetchDomainBlocksFail(error) {
+ return {
+ type: DOMAIN_BLOCKS_FETCH_FAIL,
+ error,
+ };
+};
diff --git a/app/javascript/mastodon/actions/emojis.js b/app/javascript/mastodon/actions/emojis.js
new file mode 100644
index 0000000000..7cd9d4b7b3
--- /dev/null
+++ b/app/javascript/mastodon/actions/emojis.js
@@ -0,0 +1,14 @@
+import { saveSettings } from './settings';
+
+export const EMOJI_USE = 'EMOJI_USE';
+
+export function useEmoji(emoji) {
+ return dispatch => {
+ dispatch({
+ type: EMOJI_USE,
+ emoji,
+ });
+
+ dispatch(saveSettings());
+ };
+};
diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js
new file mode 100644
index 0000000000..09ce51fceb
--- /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/height_cache.js b/app/javascript/mastodon/actions/height_cache.js
new file mode 100644
index 0000000000..4c752993fe
--- /dev/null
+++ b/app/javascript/mastodon/actions/height_cache.js
@@ -0,0 +1,17 @@
+export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET';
+export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR';
+
+export function setHeight (key, id, height) {
+ return {
+ type: HEIGHT_CACHE_SET,
+ key,
+ id,
+ height,
+ };
+};
+
+export function clearHeight () {
+ return {
+ type: HEIGHT_CACHE_CLEAR,
+ };
+};
diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js
new file mode 100644
index 0000000000..7b5f4bd9c1
--- /dev/null
+++ b/app/javascript/mastodon/actions/interactions.js
@@ -0,0 +1,313 @@
+import api from '../api';
+
+export const REBLOG_REQUEST = 'REBLOG_REQUEST';
+export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
+export const REBLOG_FAIL = 'REBLOG_FAIL';
+
+export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
+export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
+export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
+
+export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
+export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
+export const UNREBLOG_FAIL = 'UNREBLOG_FAIL';
+
+export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
+export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
+export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
+
+export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
+export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
+export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
+
+export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
+export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
+export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
+
+export const PIN_REQUEST = 'PIN_REQUEST';
+export const PIN_SUCCESS = 'PIN_SUCCESS';
+export const PIN_FAIL = 'PIN_FAIL';
+
+export const UNPIN_REQUEST = 'UNPIN_REQUEST';
+export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
+export const UNPIN_FAIL = 'UNPIN_FAIL';
+
+export function reblog(status) {
+ return function (dispatch, getState) {
+ dispatch(reblogRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) {
+ // The reblog API method returns a new status wrapped around the original. In this case we are only
+ // interested in how the original is modified, hence passing it skipping the wrapper
+ dispatch(reblogSuccess(status, response.data.reblog));
+ }).catch(function (error) {
+ dispatch(reblogFail(status, error));
+ });
+ };
+};
+
+export function unreblog(status) {
+ return (dispatch, getState) => {
+ dispatch(unreblogRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => {
+ dispatch(unreblogSuccess(status, response.data));
+ }).catch(error => {
+ dispatch(unreblogFail(status, error));
+ });
+ };
+};
+
+export function reblogRequest(status) {
+ return {
+ type: REBLOG_REQUEST,
+ status: status,
+ };
+};
+
+export function reblogSuccess(status, response) {
+ return {
+ type: REBLOG_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function reblogFail(status, error) {
+ return {
+ type: REBLOG_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
+export function unreblogRequest(status) {
+ return {
+ type: UNREBLOG_REQUEST,
+ status: status,
+ };
+};
+
+export function unreblogSuccess(status, response) {
+ return {
+ type: UNREBLOG_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function unreblogFail(status, error) {
+ return {
+ type: UNREBLOG_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
+export function favourite(status) {
+ return function (dispatch, getState) {
+ dispatch(favouriteRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) {
+ dispatch(favouriteSuccess(status, response.data));
+ }).catch(function (error) {
+ dispatch(favouriteFail(status, error));
+ });
+ };
+};
+
+export function unfavourite(status) {
+ return (dispatch, getState) => {
+ dispatch(unfavouriteRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => {
+ dispatch(unfavouriteSuccess(status, response.data));
+ }).catch(error => {
+ dispatch(unfavouriteFail(status, error));
+ });
+ };
+};
+
+export function favouriteRequest(status) {
+ return {
+ type: FAVOURITE_REQUEST,
+ status: status,
+ };
+};
+
+export function favouriteSuccess(status, response) {
+ return {
+ type: FAVOURITE_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function favouriteFail(status, error) {
+ return {
+ type: FAVOURITE_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
+export function unfavouriteRequest(status) {
+ return {
+ type: UNFAVOURITE_REQUEST,
+ status: status,
+ };
+};
+
+export function unfavouriteSuccess(status, response) {
+ return {
+ type: UNFAVOURITE_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function unfavouriteFail(status, error) {
+ return {
+ type: UNFAVOURITE_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
+export function fetchReblogs(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchReblogsRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
+ dispatch(fetchReblogsSuccess(id, response.data));
+ }).catch(error => {
+ dispatch(fetchReblogsFail(id, error));
+ });
+ };
+};
+
+export function fetchReblogsRequest(id) {
+ return {
+ type: REBLOGS_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchReblogsSuccess(id, accounts) {
+ return {
+ type: REBLOGS_FETCH_SUCCESS,
+ id,
+ accounts,
+ };
+};
+
+export function fetchReblogsFail(id, error) {
+ return {
+ type: REBLOGS_FETCH_FAIL,
+ error,
+ };
+};
+
+export function fetchFavourites(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchFavouritesRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
+ dispatch(fetchFavouritesSuccess(id, response.data));
+ }).catch(error => {
+ dispatch(fetchFavouritesFail(id, error));
+ });
+ };
+};
+
+export function fetchFavouritesRequest(id) {
+ return {
+ type: FAVOURITES_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchFavouritesSuccess(id, accounts) {
+ return {
+ type: FAVOURITES_FETCH_SUCCESS,
+ id,
+ accounts,
+ };
+};
+
+export function fetchFavouritesFail(id, error) {
+ return {
+ type: FAVOURITES_FETCH_FAIL,
+ error,
+ };
+};
+
+export function pin(status) {
+ return (dispatch, getState) => {
+ dispatch(pinRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
+ dispatch(pinSuccess(status, response.data));
+ }).catch(error => {
+ dispatch(pinFail(status, error));
+ });
+ };
+};
+
+export function pinRequest(status) {
+ return {
+ type: PIN_REQUEST,
+ status,
+ };
+};
+
+export function pinSuccess(status, response) {
+ return {
+ type: PIN_SUCCESS,
+ status,
+ response,
+ };
+};
+
+export function pinFail(status, error) {
+ return {
+ type: PIN_FAIL,
+ status,
+ error,
+ };
+};
+
+export function unpin (status) {
+ return (dispatch, getState) => {
+ dispatch(unpinRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
+ dispatch(unpinSuccess(status, response.data));
+ }).catch(error => {
+ dispatch(unpinFail(status, error));
+ });
+ };
+};
+
+export function unpinRequest(status) {
+ return {
+ type: UNPIN_REQUEST,
+ status,
+ };
+};
+
+export function unpinSuccess(status, response) {
+ return {
+ type: UNPIN_SUCCESS,
+ status,
+ response,
+ };
+};
+
+export function unpinFail(status, error) {
+ return {
+ type: UNPIN_FAIL,
+ status,
+ error,
+ };
+};
diff --git a/app/javascript/mastodon/actions/modal.js b/app/javascript/mastodon/actions/modal.js
new file mode 100644
index 0000000000..80e15c28ec
--- /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 0000000000..3474250feb
--- /dev/null
+++ b/app/javascript/mastodon/actions/mutes.js
@@ -0,0 +1,103 @@
+import api, { getLinks } from '../api';
+import { fetchRelationships } from './accounts';
+import { openModal } from '../../mastodon/actions/modal';
+
+export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
+export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
+export const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL';
+
+export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST';
+export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
+export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
+
+export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
+export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
+
+export function fetchMutes() {
+ return (dispatch, getState) => {
+ dispatch(fetchMutesRequest());
+
+ api(getState).get('/api/v1/mutes').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchMutesSuccess(response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => dispatch(fetchMutesFail(error)));
+ };
+};
+
+export function fetchMutesRequest() {
+ return {
+ type: MUTES_FETCH_REQUEST,
+ };
+};
+
+export function fetchMutesSuccess(accounts, next) {
+ return {
+ type: MUTES_FETCH_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function fetchMutesFail(error) {
+ return {
+ type: MUTES_FETCH_FAIL,
+ error,
+ };
+};
+
+export function expandMutes() {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'mutes', 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandMutesRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandMutesSuccess(response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => dispatch(expandMutesFail(error)));
+ };
+};
+
+export function expandMutesRequest() {
+ return {
+ type: MUTES_EXPAND_REQUEST,
+ };
+};
+
+export function expandMutesSuccess(accounts, next) {
+ return {
+ type: MUTES_EXPAND_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function expandMutesFail(error) {
+ return {
+ type: MUTES_EXPAND_FAIL,
+ error,
+ };
+};
+
+export function initMuteModal(account) {
+ return dispatch => {
+ dispatch({
+ type: MUTES_INIT_MODAL,
+ account,
+ });
+
+ dispatch(openModal('MUTE'));
+ };
+}
+
+export function toggleHideNotifications() {
+ return dispatch => {
+ dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
+ };
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
new file mode 100644
index 0000000000..b24ac8b733
--- /dev/null
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -0,0 +1,190 @@
+import api, { getLinks } from '../api';
+import { List as ImmutableList } from 'immutable';
+import IntlMessageFormat from 'intl-messageformat';
+import { fetchRelationships } from './accounts';
+import { defineMessages } from 'react-intl';
+
+export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
+
+export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
+export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
+export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL';
+
+export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
+export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
+export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
+
+export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
+
+defineMessages({
+ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
+});
+
+const fetchRelatedRelationships = (dispatch, notifications) => {
+ const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
+
+ if (accountIds > 0) {
+ dispatch(fetchRelationships(accountIds));
+ }
+};
+
+const unescapeHTML = (html) => {
+ const wrapper = document.createElement('div');
+ html = html.replace(/
|
|\n/, ' ');
+ wrapper.innerHTML = html;
+ return wrapper.textContent;
+};
+
+export function updateNotifications(notification, intlMessages, intlLocale) {
+ return (dispatch, getState) => {
+ const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
+ const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
+
+ dispatch({
+ type: NOTIFICATIONS_UPDATE,
+ notification,
+ account: notification.account,
+ status: notification.status,
+ meta: playSound ? { sound: 'boop' } : undefined,
+ });
+
+ fetchRelatedRelationships(dispatch, [notification]);
+
+ // Desktop notifications
+ if (typeof window.Notification !== 'undefined' && showAlert) {
+ const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
+ const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');
+
+ const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
+ notify.addEventListener('click', () => {
+ window.focus();
+ notify.close();
+ });
+ }
+ };
+};
+
+const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
+
+export function refreshNotifications() {
+ return (dispatch, getState) => {
+ const params = {};
+ const ids = getState().getIn(['notifications', 'items']);
+
+ let skipLoading = false;
+
+ if (ids.size > 0) {
+ params.since_id = ids.first().get('id');
+ }
+
+ if (getState().getIn(['notifications', 'loaded'])) {
+ skipLoading = true;
+ }
+
+ params.exclude_types = excludeTypesFromSettings(getState());
+
+ dispatch(refreshNotificationsRequest(skipLoading));
+
+ api(getState).get('/api/v1/notifications', { params }).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null));
+ fetchRelatedRelationships(dispatch, response.data);
+ }).catch(error => {
+ dispatch(refreshNotificationsFail(error, skipLoading));
+ });
+ };
+};
+
+export function refreshNotificationsRequest(skipLoading) {
+ return {
+ type: NOTIFICATIONS_REFRESH_REQUEST,
+ skipLoading,
+ };
+};
+
+export function refreshNotificationsSuccess(notifications, skipLoading, next) {
+ return {
+ type: NOTIFICATIONS_REFRESH_SUCCESS,
+ notifications,
+ accounts: notifications.map(item => item.account),
+ statuses: notifications.map(item => item.status).filter(status => !!status),
+ skipLoading,
+ next,
+ };
+};
+
+export function refreshNotificationsFail(error, skipLoading) {
+ return {
+ type: NOTIFICATIONS_REFRESH_FAIL,
+ error,
+ skipLoading,
+ };
+};
+
+export function expandNotifications() {
+ return (dispatch, getState) => {
+ const items = getState().getIn(['notifications', 'items'], ImmutableList());
+
+ if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) {
+ return;
+ }
+
+ const params = {
+ max_id: items.last().get('id'),
+ limit: 20,
+ exclude_types: excludeTypesFromSettings(getState()),
+ };
+
+ dispatch(expandNotificationsRequest());
+
+ api(getState).get('/api/v1/notifications', { params }).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
+ fetchRelatedRelationships(dispatch, response.data);
+ }).catch(error => {
+ dispatch(expandNotificationsFail(error));
+ });
+ };
+};
+
+export function expandNotificationsRequest() {
+ return {
+ type: NOTIFICATIONS_EXPAND_REQUEST,
+ };
+};
+
+export function expandNotificationsSuccess(notifications, next) {
+ return {
+ type: NOTIFICATIONS_EXPAND_SUCCESS,
+ notifications,
+ accounts: notifications.map(item => item.account),
+ statuses: notifications.map(item => item.status).filter(status => !!status),
+ next,
+ };
+};
+
+export function expandNotificationsFail(error) {
+ return {
+ type: NOTIFICATIONS_EXPAND_FAIL,
+ error,
+ };
+};
+
+export function clearNotifications() {
+ return (dispatch, getState) => {
+ dispatch({
+ type: NOTIFICATIONS_CLEAR,
+ });
+
+ api(getState).post('/api/v1/notifications/clear');
+ };
+};
+
+export function scrollTopNotifications(top) {
+ return {
+ type: NOTIFICATIONS_SCROLL_TOP,
+ top,
+ };
+};
diff --git a/app/javascript/mastodon/actions/onboarding.js b/app/javascript/mastodon/actions/onboarding.js
new file mode 100644
index 0000000000..a161c50efe
--- /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/pin_statuses.js b/app/javascript/mastodon/actions/pin_statuses.js
new file mode 100644
index 0000000000..3f40f6c2d5
--- /dev/null
+++ b/app/javascript/mastodon/actions/pin_statuses.js
@@ -0,0 +1,40 @@
+import api from '../api';
+
+export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
+export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
+export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';
+
+import { me } from '../initial_state';
+
+export function fetchPinnedStatuses() {
+ return (dispatch, getState) => {
+ dispatch(fetchPinnedStatusesRequest());
+
+ api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => {
+ dispatch(fetchPinnedStatusesSuccess(response.data, null));
+ }).catch(error => {
+ dispatch(fetchPinnedStatusesFail(error));
+ });
+ };
+};
+
+export function fetchPinnedStatusesRequest() {
+ return {
+ type: PINNED_STATUSES_FETCH_REQUEST,
+ };
+};
+
+export function fetchPinnedStatusesSuccess(statuses, next) {
+ return {
+ type: PINNED_STATUSES_FETCH_SUCCESS,
+ statuses,
+ next,
+ };
+};
+
+export function fetchPinnedStatusesFail(error) {
+ return {
+ type: PINNED_STATUSES_FETCH_FAIL,
+ error,
+ };
+};
diff --git a/app/javascript/mastodon/actions/push_notifications.js b/app/javascript/mastodon/actions/push_notifications.js
new file mode 100644
index 0000000000..55661d2b09
--- /dev/null
+++ b/app/javascript/mastodon/actions/push_notifications.js
@@ -0,0 +1,52 @@
+import axios from 'axios';
+
+export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
+export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
+export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE';
+
+export function setBrowserSupport (value) {
+ return {
+ type: SET_BROWSER_SUPPORT,
+ value,
+ };
+}
+
+export function setSubscription (subscription) {
+ return {
+ type: SET_SUBSCRIPTION,
+ subscription,
+ };
+}
+
+export function clearSubscription () {
+ return {
+ type: CLEAR_SUBSCRIPTION,
+ };
+}
+
+export function changeAlerts(key, value) {
+ return dispatch => {
+ dispatch({
+ type: ALERTS_CHANGE,
+ key,
+ value,
+ });
+
+ dispatch(saveSettings());
+ };
+}
+
+export function saveSettings() {
+ return (_, getState) => {
+ const state = getState().get('push_notifications');
+ const subscription = state.get('subscription');
+ const alerts = state.get('alerts');
+
+ axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
+ data: {
+ alerts,
+ },
+ });
+ };
+}
diff --git a/app/javascript/mastodon/actions/reports.js b/app/javascript/mastodon/actions/reports.js
new file mode 100644
index 0000000000..b19a07285b
--- /dev/null
+++ b/app/javascript/mastodon/actions/reports.js
@@ -0,0 +1,80 @@
+import api from '../api';
+import { openModal, closeModal } from './modal';
+
+export const REPORT_INIT = 'REPORT_INIT';
+export const REPORT_CANCEL = 'REPORT_CANCEL';
+
+export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST';
+export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS';
+export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL';
+
+export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE';
+export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
+
+export function initReport(account, status) {
+ return dispatch => {
+ dispatch({
+ type: REPORT_INIT,
+ account,
+ status,
+ });
+
+ dispatch(openModal('REPORT'));
+ };
+};
+
+export function cancelReport() {
+ return {
+ type: REPORT_CANCEL,
+ };
+};
+
+export function toggleStatusReport(statusId, checked) {
+ return {
+ type: REPORT_STATUS_TOGGLE,
+ statusId,
+ checked,
+ };
+};
+
+export function submitReport() {
+ return (dispatch, getState) => {
+ dispatch(submitReportRequest());
+
+ api(getState).post('/api/v1/reports', {
+ account_id: getState().getIn(['reports', 'new', 'account_id']),
+ status_ids: getState().getIn(['reports', 'new', 'status_ids']),
+ comment: getState().getIn(['reports', 'new', 'comment']),
+ }).then(response => {
+ dispatch(closeModal());
+ dispatch(submitReportSuccess(response.data));
+ }).catch(error => dispatch(submitReportFail(error)));
+ };
+};
+
+export function submitReportRequest() {
+ return {
+ type: REPORT_SUBMIT_REQUEST,
+ };
+};
+
+export function submitReportSuccess(report) {
+ return {
+ type: REPORT_SUBMIT_SUCCESS,
+ report,
+ };
+};
+
+export function submitReportFail(error) {
+ return {
+ type: REPORT_SUBMIT_FAIL,
+ error,
+ };
+};
+
+export function changeReportComment(comment) {
+ return {
+ type: REPORT_COMMENT_CHANGE,
+ comment,
+ };
+};
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
new file mode 100644
index 0000000000..78c6109f72
--- /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 0000000000..79adca18c6
--- /dev/null
+++ b/app/javascript/mastodon/actions/settings.js
@@ -0,0 +1,31 @@
+import axios from 'axios';
+import { debounce } from 'lodash';
+
+export const SETTING_CHANGE = 'SETTING_CHANGE';
+export const SETTING_SAVE = 'SETTING_SAVE';
+
+export function changeSetting(key, value) {
+ return dispatch => {
+ dispatch({
+ type: SETTING_CHANGE,
+ key,
+ value,
+ });
+
+ dispatch(saveSettings());
+ };
+};
+
+const debouncedSave = debounce((dispatch, getState) => {
+ if (getState().getIn(['settings', 'saved'])) {
+ return;
+ }
+
+ const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS();
+
+ axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
+}, 5000, { trailing: true });
+
+export function saveSettings() {
+ return (dispatch, getState) => debouncedSave(dispatch, getState);
+};
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
new file mode 100644
index 0000000000..2204e0b14a
--- /dev/null
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -0,0 +1,217 @@
+import api from '../api';
+
+import { deleteFromTimelines } from './timelines';
+import { fetchStatusCard } from './cards';
+
+export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
+export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
+export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
+
+export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
+export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
+export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
+
+export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
+export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
+export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
+
+export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
+export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
+export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL';
+
+export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
+export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
+export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
+
+export function fetchStatusRequest(id, skipLoading) {
+ return {
+ type: STATUS_FETCH_REQUEST,
+ id,
+ skipLoading,
+ };
+};
+
+export function fetchStatus(id) {
+ return (dispatch, getState) => {
+ const skipLoading = getState().getIn(['statuses', id], null) !== null;
+
+ dispatch(fetchContext(id));
+ dispatch(fetchStatusCard(id));
+
+ if (skipLoading) {
+ return;
+ }
+
+ dispatch(fetchStatusRequest(id, skipLoading));
+
+ api(getState).get(`/api/v1/statuses/${id}`).then(response => {
+ dispatch(fetchStatusSuccess(response.data, skipLoading));
+ }).catch(error => {
+ dispatch(fetchStatusFail(id, error, skipLoading));
+ });
+ };
+};
+
+export function fetchStatusSuccess(status, skipLoading) {
+ return {
+ type: STATUS_FETCH_SUCCESS,
+ status,
+ skipLoading,
+ };
+};
+
+export function fetchStatusFail(id, error, skipLoading) {
+ return {
+ type: STATUS_FETCH_FAIL,
+ id,
+ error,
+ skipLoading,
+ skipAlert: true,
+ };
+};
+
+export function deleteStatus(id) {
+ return (dispatch, getState) => {
+ dispatch(deleteStatusRequest(id));
+
+ api(getState).delete(`/api/v1/statuses/${id}`).then(() => {
+ dispatch(deleteStatusSuccess(id));
+ dispatch(deleteFromTimelines(id));
+ }).catch(error => {
+ dispatch(deleteStatusFail(id, error));
+ });
+ };
+};
+
+export function deleteStatusRequest(id) {
+ return {
+ type: STATUS_DELETE_REQUEST,
+ id: id,
+ };
+};
+
+export function deleteStatusSuccess(id) {
+ return {
+ type: STATUS_DELETE_SUCCESS,
+ id: id,
+ };
+};
+
+export function deleteStatusFail(id, error) {
+ return {
+ type: STATUS_DELETE_FAIL,
+ id: id,
+ error: error,
+ };
+};
+
+export function fetchContext(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchContextRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
+ dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
+
+ }).catch(error => {
+ if (error.response && error.response.status === 404) {
+ dispatch(deleteFromTimelines(id));
+ }
+
+ dispatch(fetchContextFail(id, error));
+ });
+ };
+};
+
+export function fetchContextRequest(id) {
+ return {
+ type: CONTEXT_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchContextSuccess(id, ancestors, descendants) {
+ return {
+ type: CONTEXT_FETCH_SUCCESS,
+ id,
+ ancestors,
+ descendants,
+ statuses: ancestors.concat(descendants),
+ };
+};
+
+export function fetchContextFail(id, error) {
+ return {
+ type: CONTEXT_FETCH_FAIL,
+ id,
+ error,
+ skipAlert: true,
+ };
+};
+
+export function muteStatus(id) {
+ return (dispatch, getState) => {
+ dispatch(muteStatusRequest(id));
+
+ api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => {
+ dispatch(muteStatusSuccess(id));
+ }).catch(error => {
+ dispatch(muteStatusFail(id, error));
+ });
+ };
+};
+
+export function muteStatusRequest(id) {
+ return {
+ type: STATUS_MUTE_REQUEST,
+ id,
+ };
+};
+
+export function muteStatusSuccess(id) {
+ return {
+ type: STATUS_MUTE_SUCCESS,
+ id,
+ };
+};
+
+export function muteStatusFail(id, error) {
+ return {
+ type: STATUS_MUTE_FAIL,
+ id,
+ error,
+ };
+};
+
+export function unmuteStatus(id) {
+ return (dispatch, getState) => {
+ dispatch(unmuteStatusRequest(id));
+
+ api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => {
+ dispatch(unmuteStatusSuccess(id));
+ }).catch(error => {
+ dispatch(unmuteStatusFail(id, error));
+ });
+ };
+};
+
+export function unmuteStatusRequest(id) {
+ return {
+ type: STATUS_UNMUTE_REQUEST,
+ id,
+ };
+};
+
+export function unmuteStatusSuccess(id) {
+ return {
+ type: STATUS_UNMUTE_SUCCESS,
+ id,
+ };
+};
+
+export function unmuteStatusFail(id, error) {
+ return {
+ type: STATUS_UNMUTE_FAIL,
+ id,
+ error,
+ };
+};
diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js
new file mode 100644
index 0000000000..a1db0fdd51
--- /dev/null
+++ b/app/javascript/mastodon/actions/store.js
@@ -0,0 +1,17 @@
+import { Iterable, fromJS } from 'immutable';
+
+export const STORE_HYDRATE = 'STORE_HYDRATE';
+export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
+
+const convertState = rawState =>
+ fromJS(rawState, (k, v) =>
+ Iterable.isIndexed(v) ? v.toList() : v.toMap());
+
+export function hydrateStore(rawState) {
+ const state = convertState(rawState);
+
+ return {
+ type: STORE_HYDRATE,
+ state,
+ };
+};
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
new file mode 100644
index 0000000000..dcce048cab
--- /dev/null
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -0,0 +1,53 @@
+import { connectStream } from '../stream';
+import {
+ updateTimeline,
+ deleteFromTimelines,
+ refreshHomeTimeline,
+ connectTimeline,
+ disconnectTimeline,
+} from './timelines';
+import { updateNotifications, refreshNotifications } from './notifications';
+import { getLocale } from '../locales';
+
+const { messages } = getLocale();
+
+export function connectTimelineStream (timelineId, path, pollingRefresh = null) {
+
+ return connectStream (path, pollingRefresh, (dispatch, getState) => {
+ const locale = getState().getIn(['meta', 'locale']);
+ return {
+ onConnect() {
+ dispatch(connectTimeline(timelineId));
+ },
+
+ onDisconnect() {
+ dispatch(disconnectTimeline(timelineId));
+ },
+
+ onReceive (data) {
+ switch(data.event) {
+ case 'update':
+ dispatch(updateTimeline(timelineId, JSON.parse(data.payload)));
+ break;
+ case 'delete':
+ dispatch(deleteFromTimelines(data.payload));
+ break;
+ case 'notification':
+ dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
+ break;
+ }
+ },
+ };
+ });
+}
+
+function refreshHomeTimelineAndNotification (dispatch) {
+ dispatch(refreshHomeTimeline());
+ dispatch(refreshNotifications());
+}
+
+export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
+export const connectCommunityStream = () => connectTimelineStream('community', 'public:local');
+export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
+export const connectPublicStream = () => connectTimelineStream('public', 'public');
+export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
new file mode 100644
index 0000000000..09abe2702a
--- /dev/null
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -0,0 +1,206 @@
+import api, { getLinks } from '../api';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
+export const TIMELINE_DELETE = 'TIMELINE_DELETE';
+
+export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST';
+export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS';
+export const TIMELINE_REFRESH_FAIL = 'TIMELINE_REFRESH_FAIL';
+
+export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
+export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
+export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
+
+export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
+
+export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
+export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
+
+export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
+
+export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
+ return {
+ type: TIMELINE_REFRESH_SUCCESS,
+ timeline,
+ statuses,
+ skipLoading,
+ next,
+ };
+};
+
+export function updateTimeline(timeline, status) {
+ return (dispatch, getState) => {
+ const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
+ const parents = [];
+
+ if (status.in_reply_to_id) {
+ let parent = getState().getIn(['statuses', status.in_reply_to_id]);
+
+ while (parent && parent.get('in_reply_to_id')) {
+ parents.push(parent.get('id'));
+ parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]);
+ }
+ }
+
+ dispatch({
+ type: TIMELINE_UPDATE,
+ timeline,
+ status,
+ references,
+ });
+
+ if (parents.length > 0) {
+ dispatch({
+ type: TIMELINE_CONTEXT_UPDATE,
+ status,
+ references: parents,
+ });
+ }
+ };
+};
+
+export function deleteFromTimelines(id) {
+ return (dispatch, getState) => {
+ const accountId = getState().getIn(['statuses', id, 'account']);
+ const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
+ const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
+
+ dispatch({
+ type: TIMELINE_DELETE,
+ id,
+ accountId,
+ references,
+ reblogOf,
+ });
+ };
+};
+
+export function refreshTimelineRequest(timeline, skipLoading) {
+ return {
+ type: TIMELINE_REFRESH_REQUEST,
+ timeline,
+ skipLoading,
+ };
+};
+
+export function refreshTimeline(timelineId, path, params = {}) {
+ return function (dispatch, getState) {
+ const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
+
+ if (timeline.get('isLoading') || timeline.get('online')) {
+ return;
+ }
+
+ const ids = timeline.get('items', ImmutableList());
+ const newestId = ids.size > 0 ? ids.first() : null;
+
+ let skipLoading = timeline.get('loaded');
+
+ if (newestId !== null) {
+ params.since_id = newestId;
+ }
+
+ dispatch(refreshTimelineRequest(timelineId, skipLoading));
+
+ api(getState).get(path, { params }).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(refreshTimelineFail(timelineId, error, skipLoading));
+ });
+ };
+};
+
+export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home');
+export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public');
+export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
+export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
+export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
+export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
+
+export function refreshTimelineFail(timeline, error, skipLoading) {
+ return {
+ type: TIMELINE_REFRESH_FAIL,
+ timeline,
+ error,
+ skipLoading,
+ skipAlert: error.response && error.response.status === 404,
+ };
+};
+
+export function expandTimeline(timelineId, path, params = {}) {
+ return (dispatch, getState) => {
+ const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
+ const ids = timeline.get('items', ImmutableList());
+
+ if (timeline.get('isLoading') || ids.size === 0) {
+ return;
+ }
+
+ params.max_id = ids.last();
+ params.limit = 10;
+
+ dispatch(expandTimelineRequest(timelineId));
+
+ api(getState).get(path, { params }).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(expandTimelineFail(timelineId, error));
+ });
+ };
+};
+
+export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home');
+export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public');
+export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true });
+export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
+export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
+export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
+
+export function expandTimelineRequest(timeline) {
+ return {
+ type: TIMELINE_EXPAND_REQUEST,
+ timeline,
+ };
+};
+
+export function expandTimelineSuccess(timeline, statuses, next) {
+ return {
+ type: TIMELINE_EXPAND_SUCCESS,
+ timeline,
+ statuses,
+ next,
+ };
+};
+
+export function expandTimelineFail(timeline, error) {
+ return {
+ type: TIMELINE_EXPAND_FAIL,
+ timeline,
+ error,
+ };
+};
+
+export function scrollTopTimeline(timeline, top) {
+ return {
+ type: TIMELINE_SCROLL_TOP,
+ timeline,
+ top,
+ };
+};
+
+export function connectTimeline(timeline) {
+ return {
+ type: TIMELINE_CONNECT,
+ timeline,
+ };
+};
+
+export function disconnectTimeline(timeline) {
+ return {
+ type: TIMELINE_DISCONNECT,
+ timeline,
+ };
+};
diff --git a/app/javascript/mastodon/api.js b/app/javascript/mastodon/api.js
new file mode 100644
index 0000000000..ecc703c0aa
--- /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/base_polyfills.js b/app/javascript/mastodon/base_polyfills.js
new file mode 100644
index 0000000000..7856b26f9d
--- /dev/null
+++ b/app/javascript/mastodon/base_polyfills.js
@@ -0,0 +1,18 @@
+import 'intl';
+import 'intl/locale-data/jsonp/en';
+import 'es6-symbol/implement';
+import includes from 'array-includes';
+import assign from 'object-assign';
+import isNaN from 'is-nan';
+
+if (!Array.prototype.includes) {
+ includes.shim();
+}
+
+if (!Object.assign) {
+ Object.assign = assign;
+}
+
+if (!Number.isNaN) {
+ Number.isNaN = isNaN;
+}
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap
new file mode 100644
index 0000000000..76ab3374ae
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap
@@ -0,0 +1,33 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`
children
; + const component = renderer.create(); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders the props.text instead of children', () => { + const text = 'foo'; + const children =children
; + const component = renderer.create(); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders class="button--block" if props.block given', () => { + const component = renderer.create(); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('adds class "button-secondary" if props.secondary given', () => { + const component = renderer.create(); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/app/javascript/mastodon/components/__tests__/display_name-test.js b/app/javascript/mastodon/components/__tests__/display_name-test.js new file mode 100644 index 0000000000..0d040c4cd8 --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/display_name-test.js @@ -0,0 +1,18 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { fromJS } from 'immutable'; +import DisplayName from '../display_name'; + +describe('Foo
', + }); + const component = renderer.create(+ + {' '} + +
+ + {mentionsPlaceholder} + + +{(card.get('description') || '').substring(0, maxDescription)}
+ {provider} +
+
+
+
+
+
/g, '\n\n'); + + const emojiMap = normalStatus.emojis.reduce((obj, emoji) => { + obj[`:${emoji.shortcode}:`] = emoji; + return obj; + }, {}); + + normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; + normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); + normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap); + + return state.update(status.id, ImmutableMap(), map => map.mergeDeep(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 = ImmutableMap(); + +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: + case PIN_SUCCESS: + case UNPIN_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 STATUS_MUTE_SUCCESS: + return state.setIn([action.id, 'muted'], true); + case STATUS_UNMUTE_SUCCESS: + return state.setIn([action.id, 'muted'], false); + case TIMELINE_REFRESH_SUCCESS: + case 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 PINNED_STATUSES_FETCH_SUCCESS: + case SEARCH_FETCH_SUCCESS: + return normalizeStatuses(state, action.statuses); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.references); + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_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 0000000000..bee4c4ef92 --- /dev/null +++ b/app/javascript/mastodon/reducers/timelines.js @@ -0,0 +1,149 @@ +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 { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS, +} from '../actions/accounts'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +const initialState = ImmutableMap(); + +const initialTimeline = ImmutableMap({ + unread: 0, + online: false, + top: true, + loaded: false, + isLoading: false, + next: false, + items: ImmutableList(), +}); + +const normalizeTimeline = (state, timeline, statuses, next) => { + const oldIds = state.getIn([timeline, 'items'], ImmutableList()); + const ids = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId)); + const wasLoaded = state.getIn([timeline, 'loaded']); + const hadNext = state.getIn([timeline, 'next']); + + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + mMap.set('loaded', true); + mMap.set('isLoading', false); + if (!hadNext) mMap.set('next', next); + mMap.set('items', wasLoaded ? ids.concat(oldIds) : ids); + })); +}; + +const appendNormalizedTimeline = (state, timeline, statuses, next) => { + const oldIds = state.getIn([timeline, 'items'], ImmutableList()); + const ids = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId)); + + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + mMap.set('isLoading', false); + mMap.set('next', next); + mMap.set('items', oldIds.concat(ids)); + })); +}; + +const updateTimeline = (state, timeline, status, references) => { + const top = state.getIn([timeline, 'top']); + const ids = state.getIn([timeline, 'items'], ImmutableList()); + const includesId = ids.includes(status.get('id')); + const unread = state.getIn([timeline, 'unread'], 0); + + if (includesId) { + return state; + } + + let newIds = ids; + + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + if (!top) mMap.set('unread', unread + 1); + if (top && ids.size > 40) newIds = newIds.take(20); + if (status.getIn(['reblog', 'id'], null) !== null) newIds = newIds.filterNot(item => references.includes(item)); + mMap.set('items', newIds.unshift(status.get('id'))); + })); +}; + +const deleteStatus = (state, id, accountId, references) => { + state.keySeq().forEach(timeline => { + state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === 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 filterTimeline = (timeline, state, relationship, statuses) => + state.updateIn([timeline, 'items'], ImmutableList(), list => + list.filterNot(statusId => + statuses.getIn([statusId, 'account']) === relationship.id + )); + +const updateTop = (state, timeline, top) => { + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + if (top) mMap.set('unread', 0); + mMap.set('top', top); + })); +}; + +export default function timelines(state = initialState, action) { + switch(action.type) { + case TIMELINE_REFRESH_REQUEST: + case TIMELINE_EXPAND_REQUEST: + return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true)); + case TIMELINE_REFRESH_FAIL: + case TIMELINE_EXPAND_FAIL: + return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false)); + case TIMELINE_REFRESH_SUCCESS: + return normalizeTimeline(state, action.timeline, fromJS(action.statuses), action.next); + case TIMELINE_EXPAND_SUCCESS: + return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next); + case TIMELINE_UPDATE: + return updateTimeline(state, action.timeline, fromJS(action.status), action.references); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf); + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return filterTimelines(state, action.relationship, action.statuses); + case ACCOUNT_UNFOLLOW_SUCCESS: + return filterTimeline('home', state, action.relationship, action.statuses); + case TIMELINE_SCROLL_TOP: + return updateTop(state, action.timeline, action.top); + case TIMELINE_CONNECT: + return state.update(action.timeline, initialTimeline, map => map.set('online', true)); + case TIMELINE_DISCONNECT: + return state.update(action.timeline, initialTimeline, map => map.set('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 0000000000..8db18c5dc6 --- /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 { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +const initialState = ImmutableMap({ + followers: ImmutableMap(), + following: ImmutableMap(), + reblogged_by: ImmutableMap(), + favourited_by: ImmutableMap(), + follow_requests: ImmutableMap(), + blocks: ImmutableMap(), + mutes: ImmutableMap(), +}); + +const normalizeList = (state, type, id, accounts, next) => { + return state.setIn([type, id], ImmutableMap({ + next, + items: ImmutableList(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], ImmutableList(action.accounts.map(item => item.id))); + case FAVOURITES_FETCH_SUCCESS: + return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id))); + case FOLLOW_REQUESTS_FETCH_SUCCESS: + return state.setIn(['follow_requests', 'items'], ImmutableList(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'], ImmutableList(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'], ImmutableList(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 0000000000..00870a15d6 --- /dev/null +++ b/app/javascript/mastodon/rtl.js @@ -0,0 +1,31 @@ +// 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; + } + + text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, ''); + text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, ''); + text = text.replace(/\s+/g, ''); + + const matches = text.match(rtlChars); + + if (!matches) { + return false; + } + + return matches.length / text.length > 0.3; +}; diff --git a/app/javascript/mastodon/scroll.js b/app/javascript/mastodon/scroll.js new file mode 100644 index 0000000000..2af07e0fb1 --- /dev/null +++ b/app/javascript/mastodon/scroll.js @@ -0,0 +1,30 @@ +const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b; + +const scroll = (node, key, target) => { + const startTime = Date.now(); + const offset = node[key]; + const gap = target - offset; + const duration = 1000; + let interrupt = false; + + const step = () => { + const elapsed = Date.now() - startTime; + const percentage = elapsed / duration; + + if (percentage > 1 || interrupt) { + return; + } + + node[key] = easingOutQuint(0, elapsed, offset, gap, duration); + requestAnimationFrame(step); + }; + + step(); + + return () => { + interrupt = true; + }; +}; + +export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position); +export const scrollTop = (node) => scroll(node, 'scrollTop', 0); diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js new file mode 100644 index 0000000000..d26d1b727f --- /dev/null +++ b/app/javascript/mastodon/selectors/index.js @@ -0,0 +1,87 @@ +import { createSelector } from 'reselect'; +import { List as ImmutableList } from 'immutable'; + +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, + barStyle: { + zIndex: 200, + }, + }); + }); + + return arr; +}); + +export const makeGetNotification = () => { + return createSelector([ + (_, base) => base, + (state, _, accountId) => state.getIn(['accounts', accountId]), + ], (base, account) => { + return base.set('account', account); + }); +}; + +export const getAccountGallery = createSelector([ + (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()), + state => state.get('statuses'), +], (statusIds, statuses) => { + let medias = ImmutableList(); + + statusIds.forEach(statusId => { + const status = statuses.get(statusId); + medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status))); + }); + + return medias; +}); diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js new file mode 100644 index 0000000000..eea4cfc3c2 --- /dev/null +++ b/app/javascript/mastodon/service_worker/entry.js @@ -0,0 +1,10 @@ +import './web_push_notifications'; + +// Cause a new version of a registered Service Worker to replace an existing one +// that is already installed, and replace the currently active worker on open pages. +self.addEventListener('install', function(event) { + event.waitUntil(self.skipWaiting()); +}); +self.addEventListener('activate', function(event) { + event.waitUntil(self.clients.claim()); +}); diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js new file mode 100644 index 0000000000..f63cff335a --- /dev/null +++ b/app/javascript/mastodon/service_worker/web_push_notifications.js @@ -0,0 +1,159 @@ +const MAX_NOTIFICATIONS = 5; +const GROUP_TAG = 'tag'; + +// Avoid loading intl-messageformat and dealing with locales in the ServiceWorker +const formatGroupTitle = (message, count) => message.replace('%{count}', count); + +const notify = options => + self.registration.getNotifications().then(notifications => { + if (notifications.length === MAX_NOTIFICATIONS) { + // Reached the maximum number of notifications, proceed with grouping + const group = { + title: formatGroupTitle(options.data.message, notifications.length + 1), + body: notifications + .sort((n1, n2) => n1.timestamp < n2.timestamp) + .map(notification => notification.title).join('\n'), + badge: '/badge.png', + icon: '/android-chrome-192x192.png', + tag: GROUP_TAG, + data: { + url: (new URL('/web/notifications', self.location)).href, + count: notifications.length + 1, + message: options.data.message, + }, + }; + + notifications.forEach(notification => notification.close()); + + return self.registration.showNotification(group.title, group); + } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) { + // Already grouped, proceed with appending the notification to the group + const group = cloneNotification(notifications[0]); + + group.title = formatGroupTitle(group.data.message, group.data.count + 1); + group.body = `${options.title}\n${group.body}`; + group.data = { ...group.data, count: group.data.count + 1 }; + + return self.registration.showNotification(group.title, group); + } + + return self.registration.showNotification(options.title, options); + }); + +const handlePush = (event) => { + const options = event.data.json(); + + options.body = options.data.nsfw || options.data.content; + options.dir = options.data.dir; + options.image = options.image || undefined; // Null results in a network request (404) + options.timestamp = options.timestamp && new Date(options.timestamp); + + const expandAction = options.data.actions.find(action => action.todo === 'expand'); + + if (expandAction) { + options.actions = [expandAction]; + options.hiddenActions = options.data.actions.filter(action => action !== expandAction); + options.data.hiddenImage = options.image; + options.image = undefined; + } else { + options.actions = options.data.actions; + } + + event.waitUntil(notify(options)); +}; + +const cloneNotification = (notification) => { + const clone = { }; + + for(var k in notification) { + clone[k] = notification[k]; + } + + return clone; +}; + +const expandNotification = (notification) => { + const nextNotification = cloneNotification(notification); + + nextNotification.body = notification.data.content; + nextNotification.image = notification.data.hiddenImage; + nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand'); + + return self.registration.showNotification(nextNotification.title, nextNotification); +}; + +const makeRequest = (notification, action) => + fetch(action.action, { + headers: { + 'Authorization': `Bearer ${notification.data.access_token}`, + 'Content-Type': 'application/json', + }, + method: action.method, + credentials: 'include', + }); + +const findBestClient = clients => { + const focusedClient = clients.find(client => client.focused); + const visibleClient = clients.find(client => client.visibilityState === 'visible'); + + return focusedClient || visibleClient || clients[0]; +}; + +const openUrl = url => + self.clients.matchAll({ type: 'window' }).then(clientList => { + if (clientList.length !== 0) { + const webClients = clientList.filter(client => /\/web\//.test(client.url)); + + if (webClients.length !== 0) { + const client = findBestClient(webClients); + const { pathname } = new URL(url); + + if (pathname.startsWith('/web/')) { + return client.focus().then(client => client.postMessage({ + type: 'navigate', + path: pathname.slice('/web/'.length - 1), + })); + } + } else if ('navigate' in clientList[0]) { // Chrome 42-48 does not support navigate + const client = findBestClient(clientList); + + return client.navigate(url).then(client => client.focus()); + } + } + + return self.clients.openWindow(url); + }); + +const removeActionFromNotification = (notification, action) => { + const actions = notification.actions.filter(act => act.action !== action.action); + const nextNotification = cloneNotification(notification); + + nextNotification.actions = actions; + + return self.registration.showNotification(nextNotification.title, nextNotification); +}; + +const handleNotificationClick = (event) => { + const reactToNotificationClick = new Promise((resolve, reject) => { + if (event.action) { + const action = event.notification.data.actions.find(({ action }) => action === event.action); + + if (action.todo === 'expand') { + resolve(expandNotification(event.notification)); + } else if (action.todo === 'request') { + resolve(makeRequest(event.notification, action) + .then(() => removeActionFromNotification(event.notification, action))); + } else { + reject(`Unknown action: ${action.todo}`); + } + } else { + event.notification.close(); + resolve(openUrl(event.notification.data.url)); + } + }); + + event.waitUntil(reactToNotificationClick); +}; + +self.addEventListener('push', handlePush); +self.addEventListener('notificationclick', handleNotificationClick); diff --git a/app/javascript/mastodon/store/configureStore.js b/app/javascript/mastodon/store/configureStore.js new file mode 100644 index 0000000000..1376d4cbaf --- /dev/null +++ b/app/javascript/mastodon/store/configureStore.js @@ -0,0 +1,15 @@ +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'; + +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 0000000000..36c68ffc5b --- /dev/null +++ b/app/javascript/mastodon/stream.js @@ -0,0 +1,73 @@ +import WebSocketClient from 'websocket.js'; + +export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) { + return (dispatch, getState) => { + const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); + const accessToken = getState().getIn(['meta', 'access_token']); + const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState); + let polling = null; + + const setupPolling = () => { + polling = setInterval(() => { + pollingRefresh(dispatch); + }, 20000); + }; + + const clearPolling = () => { + if (polling) { + clearInterval(polling); + polling = null; + } + }; + + const subscription = getStream(streamingAPIBaseURL, accessToken, path, { + connected () { + if (pollingRefresh) { + clearPolling(); + } + onConnect(); + }, + + disconnected () { + if (pollingRefresh) { + setupPolling(); + } + onDisconnect(); + }, + + received (data) { + onReceive(data); + }, + + reconnected () { + if (pollingRefresh) { + clearPolling(); + pollingRefresh(dispatch); + } + onConnect(); + }, + + }); + + const disconnect = () => { + if (subscription) { + subscription.close(); + } + clearPolling(); + }; + + return disconnect; + }; +} + + +export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) { + const ws = new WebSocketClient(`${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/test_setup.js b/app/javascript/mastodon/test_setup.js new file mode 100644 index 0000000000..80148379b2 --- /dev/null +++ b/app/javascript/mastodon/test_setup.js @@ -0,0 +1,5 @@ +import { configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +const adapter = new Adapter(); +configure({ adapter }); diff --git a/app/javascript/mastodon/uuid.js b/app/javascript/mastodon/uuid.js new file mode 100644 index 0000000000..be18993057 --- /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); +}; diff --git a/app/javascript/mastodon/web_push_subscription.js b/app/javascript/mastodon/web_push_subscription.js new file mode 100644 index 0000000000..3dbed09ea2 --- /dev/null +++ b/app/javascript/mastodon/web_push_subscription.js @@ -0,0 +1,105 @@ +import axios from 'axios'; +import { store } from './containers/mastodon'; +import { setBrowserSupport, setSubscription, clearSubscription } from './actions/push_notifications'; + +// Taken from https://www.npmjs.com/package/web-push +const urlBase64ToUint8Array = (base64String) => { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +}; + +const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content'); + +const getRegistration = () => navigator.serviceWorker.ready; + +const getPushSubscription = (registration) => + registration.pushManager.getSubscription() + .then(subscription => ({ registration, subscription })); + +const subscribe = (registration) => + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()), + }); + +const unsubscribe = ({ registration, subscription }) => + subscription ? subscription.unsubscribe().then(() => registration) : registration; + +const sendSubscriptionToBackend = (subscription) => + axios.post('/api/web/push_subscriptions', { + subscription, + }).then(response => response.data); + +// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload +const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype); + +export function register () { + store.dispatch(setBrowserSupport(supportsPushNotifications)); + + if (supportsPushNotifications) { + if (!getApplicationServerKey()) { + console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.'); + return; + } + + getRegistration() + .then(getPushSubscription) + .then(({ registration, subscription }) => { + if (subscription !== null) { + // We have a subscription, check if it is still valid + const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString(); + const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString(); + const serverEndpoint = store.getState().getIn(['push_notifications', 'subscription', 'endpoint']); + + // If the VAPID public key did not change and the endpoint corresponds + // to the endpoint saved in the backend, the subscription is valid + if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) { + return subscription; + } else { + // Something went wrong, try to subscribe again + return unsubscribe({ registration, subscription }).then(subscribe).then(sendSubscriptionToBackend); + } + } + + // No subscription, try to subscribe + return subscribe(registration).then(sendSubscriptionToBackend); + }) + .then(subscription => { + // If we got a PushSubscription (and not a subscription object from the backend) + // it means that the backend subscription is valid (and was set during hydration) + if (!(subscription instanceof PushSubscription)) { + store.dispatch(setSubscription(subscription)); + } + }) + .catch(error => { + if (error.code === 20 && error.name === 'AbortError') { + console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.'); + } else if (error.code === 5 && error.name === 'InvalidCharacterError') { + console.error('The VAPID public key seems to be invalid:', getApplicationServerKey()); + } + + // Clear alerts and hide UI settings + store.dispatch(clearSubscription()); + + try { + getRegistration() + .then(getPushSubscription) + .then(unsubscribe); + } catch (e) { + + } + }); + } else { + console.warn('Your browser does not support Web Push Notifications.'); + } +} diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss new file mode 100644 index 0000000000..44aa105645 --- /dev/null +++ b/app/javascript/styles/application.scss @@ -0,0 +1,22 @@ +@import 'mastodon/mixins'; +@import 'mastodon/variables'; +@import 'fonts/roboto'; +@import 'fonts/roboto-mono'; +@import 'fonts/montserrat'; + +@import 'mastodon/reset'; +@import 'mastodon/basics'; +@import 'mastodon/containers'; +@import 'mastodon/lists'; +@import 'mastodon/footer'; +@import 'mastodon/compact_header'; +@import 'mastodon/landing_strip'; +@import 'mastodon/forms'; +@import 'mastodon/accounts'; +@import 'mastodon/stream_entries'; +@import 'mastodon/components'; +@import 'mastodon/emoji_picker'; +@import 'mastodon/about'; +@import 'mastodon/tables'; +@import 'mastodon/admin'; +@import 'mastodon/rtl'; diff --git a/app/javascript/styles/mastodon/_mixins.scss b/app/javascript/styles/mastodon/_mixins.scss new file mode 100644 index 0000000000..67d768a6c6 --- /dev/null +++ b/app/javascript/styles/mastodon/_mixins.scss @@ -0,0 +1,12 @@ +@mixin avatar-radius() { + border-radius: 4px; + background: transparent no-repeat; + background-position: 50%; + background-clip: padding-box; +} + +@mixin avatar-size($size:48px) { + width: $size; + height: $size; + background-size: $size $size; +} diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss new file mode 100644 index 0000000000..358d86eecc --- /dev/null +++ b/app/javascript/styles/mastodon/about.scss @@ -0,0 +1,824 @@ +.landing-page { + p, + li { + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + margin-bottom: 12px; + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + text-decoration: underline; + } + } + + em { + display: inline; + margin: 0; + padding: 0; + font-weight: 500; + background: transparent; + font-family: inherit; + font-size: inherit; + line-height: inherit; + color: lighten($ui-primary-color, 10%); + } + + h1 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 26px; + line-height: 30px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + + small { + font-family: 'mastodon-font-sans-serif', sans-serif; + display: block; + font-size: 18px; + font-weight: 400; + color: $ui-base-lighter-color; + } + } + + h2 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 22px; + line-height: 26px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h3 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 18px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h4 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h5 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 14px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h6 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 12px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + ul, + ol { + margin-left: 20px; + + &[type='a'] { + list-style-type: lower-alpha; + } + + &[type='i'] { + list-style-type: lower-roman; + } + } + + ul { + list-style: disc; + } + + ol { + list-style: decimal; + } + + li > ol, + li > ul { + margin-top: 6px; + } + + hr { + border-color: rgba($ui-base-lighter-color, .6); + } + + .container { + width: 100%; + box-sizing: border-box; + max-width: 800px; + margin: 0 auto; + word-wrap: break-word; + } + + .header-wrapper { + padding-top: 15px; + background: $ui-base-color; + background: linear-gradient(150deg, lighten($ui-base-color, 8%), $ui-base-color); + position: relative; + + &.compact { + background: $ui-base-color; + padding-bottom: 15px; + + .hero .heading { + padding-bottom: 20px; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + text-decoration: underline; + } + } + } + + .mascot-container { + max-width: 800px; + margin: 0 auto; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 100%; + } + + .mascot { + position: absolute; + bottom: -14px; + width: auto; + height: auto; + left: 60px; + z-index: 3; + } + } + + .header { + line-height: 30px; + overflow: hidden; + + .container { + display: flex; + justify-content: space-between; + } + + .links { + position: relative; + z-index: 4; + + a { + display: flex; + justify-content: center; + align-items: center; + color: $ui-primary-color; + text-decoration: none; + padding: 12px 16px; + line-height: 32px; + font-family: 'mastodon-font-display', sans-serif; + font-weight: 500; + font-size: 14px; + + &:hover { + color: $ui-secondary-color; + } + } + + .brand { + a { + padding-left: 0; + padding-right: 0; + color: $white; + } + + img { + height: 32px; + position: relative; + top: 4px; + left: -10px; + } + } + + ul { + list-style: none; + margin: 0; + + li { + display: inline-block; + vertical-align: bottom; + margin: 0; + + &:first-child a { + padding-left: 0; + } + + &:last-child a { + padding-right: 0; + } + } + } + } + + .hero { + margin-top: 50px; + align-items: center; + position: relative; + + .floats { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + + div { + position: absolute; + transition: all 0.1s linear; + animation-name: floating; + animation-iteration-count: infinite; + animation-direction: alternate; + animation-timing-function: ease-in-out; + z-index: 2; + } + + .float-1 { + width: 324px; + height: 170px; + right: -120px; + bottom: 0; + animation-duration: 3s; + background-image: url('data:image/svg+xml;utf8,'); + } + + .float-2 { + width: 241px; + height: 100px; + right: 210px; + bottom: 0; + animation-duration: 3.5s; + animation-delay: 0.2s; + background-image: url('data:image/svg+xml;utf8,'); + } + + .float-3 { + width: 267px; + height: 140px; + right: 110px; + top: -30px; + animation-duration: 4s; + animation-delay: 0.5s; + background-image: url('data:image/svg+xml;utf8,'); + } + } + + .heading { + position: relative; + z-index: 4; + padding-bottom: 150px; + } + + .simple_form, + .closed-registrations-message { + background: darken($ui-base-color, 4%); + width: 280px; + padding: 15px 20px; + border-radius: 4px 4px 0 0; + line-height: initial; + position: relative; + z-index: 4; + + .actions { + margin-bottom: 0; + + button, + .button, + .block-button { + margin-bottom: 0; + } + } + } + + .closed-registrations-message { + min-height: 330px; + display: flex; + flex-direction: column; + justify-content: space-between; + } + } + } + + .about-short { + background: darken($ui-base-color, 4%); + padding: 50px 0 30px; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + text-decoration: underline; + } + } + + .information-board { + background: darken($ui-base-color, 4%); + padding: 20px 0; + + .container { + position: relative; + padding-right: 280px + 15px; + } + + .information-board-sections { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + } + + .section { + flex: 1 0 0; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + line-height: 28px; + color: $primary-text-color; + text-align: right; + padding: 10px 15px; + + span, + strong { + display: block; + } + + span { + &:last-child { + color: $ui-secondary-color; + } + } + + strong { + font-weight: 500; + font-size: 32px; + line-height: 48px; + } + } + + .panel { + position: absolute; + width: 280px; + box-sizing: border-box; + background: darken($ui-base-color, 8%); + padding: 20px; + padding-top: 10px; + border-radius: 4px 4px 0 0; + right: 0; + bottom: -40px; + + .panel-header { + font-family: 'mastodon-font-display', sans-serif; + font-size: 14px; + line-height: 24px; + font-weight: 500; + color: $ui-primary-color; + padding-bottom: 5px; + margin-bottom: 15px; + border-bottom: 1px solid lighten($ui-base-color, 4%); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + a, + span { + font-weight: 400; + color: darken($ui-primary-color, 10%); + } + + a { + text-decoration: none; + } + } + } + + .owner { + text-align: center; + + .avatar { + width: 80px; + height: 80px; + margin: 0 auto; + margin-bottom: 15px; + + img { + display: block; + width: 80px; + height: 80px; + border-radius: 48px; + } + } + + .name { + font-size: 14px; + + a { + display: block; + color: $primary-text-color; + text-decoration: none; + + &:hover { + .display_name { + text-decoration: underline; + } + } + } + + .username { + display: block; + color: $ui-primary-color; + } + } + } + } + + .features { + padding: 50px 0; + + .container { + display: flex; + } + + #mastodon-timeline { + display: flex; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 13px; + line-height: 18px; + font-weight: 400; + color: $primary-text-color; + width: 330px; + margin-right: 30px; + flex: 0 0 auto; + background: $ui-base-color; + overflow: hidden; + border-radius: 4px; + box-shadow: 0 0 6px rgba($black, 0.1); + + .column-header { + color: inherit; + font-family: inherit; + font-size: 16px; + line-height: inherit; + font-weight: inherit; + margin: 0; + padding: 15px; + } + + .column { + padding: 0; + border-radius: 4px; + overflow: hidden; + } + + .scrollable { + height: 400px; + } + + p { + font-size: inherit; + line-height: inherit; + font-weight: inherit; + color: $primary-text-color; + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + a { + color: $ui-secondary-color; + text-decoration: none; + } + } + } + + .about-mastodon { + max-width: 675px; + + p { + margin-bottom: 20px; + } + + .features-list { + margin-top: 20px; + + .features-list__row { + display: flex; + padding: 10px 0; + justify-content: space-between; + + &:first-child { + padding-top: 0; + } + + .visual { + flex: 0 0 auto; + display: flex; + align-items: center; + margin-left: 15px; + + .fa { + display: block; + color: $ui-primary-color; + font-size: 48px; + } + } + + .text { + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; + + h6 { + font-size: inherit; + line-height: inherit; + margin-bottom: 0; + } + } + } + } + } + } + + .extended-description { + padding: 50px 0; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + text-decoration: underline; + } + } + + .footer-links { + padding-bottom: 50px; + text-align: right; + color: $ui-base-lighter-color; + + p { + font-size: 14px; + } + + a { + color: inherit; + text-decoration: underline; + } + } + + @media screen and (max-width: 840px) { + .container { + padding: 0 20px; + } + + .information-board { + + .container { + padding-right: 20px; + } + + .section { + text-align: center; + } + + .panel { + position: static; + margin-top: 20px; + width: 100%; + border-radius: 4px; + + .panel-header { + text-align: center; + } + } + } + + .header-wrapper .mascot { + left: 20px; + } + } + + @media screen and (max-width: 689px) { + .header-wrapper .mascot { + display: none; + } + } + + @media screen and (max-width: 675px) { + .header-wrapper { + padding-top: 0; + + &.compact { + padding-bottom: 0; + } + + &.compact .hero .heading { + text-align: initial; + } + } + + .header .container, + .features .container { + display: block; + } + + .header { + + .links { + padding-top: 15px; + background: darken($ui-base-color, 4%); + + a { + padding: 12px 8px; + } + + .nav { + display: flex; + flex-flow: row wrap; + justify-content: space-around; + } + + .brand img { + left: 0; + top: 0; + } + } + + .hero { + margin-top: 30px; + padding: 0; + + .floats { + display: none; + } + + .heading { + padding: 30px 20px; + text-align: center; + } + + .simple_form, + .closed-registrations-message { + background: darken($ui-base-color, 8%); + width: 100%; + border-radius: 0; + box-sizing: border-box; + } + } + } + + .features #mastodon-timeline { + height: 70vh; + width: 100%; + margin-bottom: 50px; + + .column { + width: 100%; + } + } + } + + .cta { + margin: 20px; + } + + &.tag-page { + .features { + padding: 30px 0; + + .container { + max-width: 820px; + + #mastodon-timeline { + margin-right: 0; + border-top-right-radius: 0; + } + + .about-mastodon { + .about-hashtag { + background: darken($ui-base-color, 4%); + padding: 0 20px 20px 30px; + border-radius: 0 5px 5px 0; + + .brand { + padding-top: 20px; + margin-bottom: 20px; + + img { + height: 48px; + width: auto; + } + } + + p { + strong { + color: $ui-secondary-color; + font-weight: 700; + } + } + + .cta { + margin: 0; + + .button { + margin-right: 4px; + } + } + } + + .features-list { + margin-left: 30px; + margin-right: 10px; + } + } + } + } + + @media screen and (max-width: 675px) { + .features { + padding: 10px 0; + + .container { + display: flex; + flex-direction: column; + + #mastodon-timeline { + order: 2; + flex: 0 0 auto; + height: 60vh; + margin-bottom: 20px; + border-top-right-radius: 4px; + } + + .about-mastodon { + order: 1; + flex: 0 0 auto; + max-width: 100%; + + .about-hashtag { + background: unset; + padding: 0; + border-radius: 0; + + .cta { + margin: 20px 0; + } + } + + .features-list { + display: none; + } + } + } + } + } + } +} + +@keyframes floating { + from { + transform: translate(0, 0); + } + + 65% { + transform: translate(0, 4px); + } + + to { + transform: translate(0, -0); + } +} diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss new file mode 100644 index 0000000000..23e20a3668 --- /dev/null +++ b/app/javascript/styles/mastodon/accounts.scss @@ -0,0 +1,549 @@ +.card { + background-color: lighten($ui-base-color, 4%); + background-size: cover; + background-position: center; + border-radius: 4px 4px 0 0; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + overflow: hidden; + position: relative; + display: flex; + + &::after { + background: rgba(darken($ui-base-color, 8%), 0.5); + display: block; + content: ""; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + } + + @media screen and (max-width: 740px) { + border-radius: 0; + box-shadow: none; + } + + .card__illustration { + padding: 60px 0; + position: relative; + flex: 1 1 auto; + display: flex; + justify-content: center; + align-items: center; + } + + .card__bio { + max-width: 260px; + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: space-between; + background: rgba(darken($ui-base-color, 8%), 0.8); + position: relative; + z-index: 2; + } + + &.compact { + padding: 30px 0; + border-radius: 4px; + + .avatar { + margin-bottom: 0; + + img { + object-fit: cover; + } + } + } + + .name { + display: block; + font-size: 20px; + line-height: 18px * 1.5; + color: $primary-text-color; + padding: 10px 15px; + padding-bottom: 0; + font-weight: 500; + position: relative; + z-index: 2; + margin-bottom: 30px; + overflow: hidden; + text-overflow: ellipsis; + + small { + display: block; + font-size: 14px; + color: $ui-highlight-color; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .avatar { + width: 120px; + margin: 0 auto; + position: relative; + z-index: 2; + + img { + width: 120px; + height: 120px; + display: block; + border-radius: 120px; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + } + } + + .controls { + position: absolute; + top: 15px; + left: 15px; + z-index: 2; + + .icon-button { + color: rgba($white, 0.8); + text-decoration: none; + font-size: 13px; + line-height: 13px; + font-weight: 500; + + .fa { + font-weight: 400; + margin-right: 5px; + } + + &:hover, + &:active, + &:focus { + color: $white; + } + } + } + + .roles { + margin-bottom: 30px; + padding: 0 15px; + } + + .details-counters { + margin-top: 30px; + display: flex; + flex-direction: row; + width: 100%; + } + + .counter { + width: 33.3%; + box-sizing: border-box; + flex: 0 0 auto; + color: $ui-primary-color; + padding: 5px 10px 0; + margin-bottom: 10px; + border-right: 1px solid lighten($ui-base-color, 4%); + cursor: default; + text-align: center; + position: relative; + + a { + display: block; + } + + &:last-child { + border-right: 0; + } + + &::after { + display: block; + content: ""; + position: absolute; + bottom: -10px; + left: 0; + width: 100%; + border-bottom: 4px solid $ui-primary-color; + opacity: 0.5; + transition: all 400ms ease; + } + + &.active { + &::after { + border-bottom: 4px solid $ui-highlight-color; + opacity: 1; + } + } + + &:hover { + &::after { + opacity: 1; + transition-duration: 100ms; + } + } + + a { + text-decoration: none; + color: inherit; + } + + .counter-label { + font-size: 12px; + display: block; + margin-bottom: 5px; + } + + .counter-number { + font-weight: 500; + font-size: 18px; + color: $primary-text-color; + font-family: 'mastodon-font-display', sans-serif; + } + } + + .bio { + font-size: 14px; + line-height: 18px; + padding: 0 15px; + color: $ui-secondary-color; + } + + @media screen and (max-width: 480px) { + display: block; + + .card__bio { + max-width: none; + } + + .name, + .roles { + text-align: center; + margin-bottom: 15px; + } + + .bio { + margin-bottom: 15px; + } + } +} + +.pagination { + padding: 30px 0; + text-align: center; + overflow: hidden; + + a, + .current, + .next, + .prev, + .page, + .gap { + font-size: 14px; + color: $primary-text-color; + font-weight: 500; + display: inline-block; + padding: 6px 10px; + text-decoration: none; + } + + .current { + background: $simple-background-color; + border-radius: 100px; + color: $ui-base-color; + cursor: default; + margin: 0 10px; + } + + .gap { + cursor: default; + } + + .prev, + .next { + text-transform: uppercase; + color: $ui-secondary-color; + } + + .prev { + float: left; + padding-left: 0; + + .fa { + display: inline-block; + margin-right: 5px; + } + } + + .next { + float: right; + padding-right: 0; + + .fa { + display: inline-block; + margin-left: 5px; + } + } + + .disabled { + cursor: default; + color: lighten($ui-base-color, 10%); + } + + @media screen and (max-width: 700px) { + padding: 30px 20px; + + .page { + display: none; + } + + .next, + .prev { + display: inline-block; + } + } +} + +.accounts-grid { + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + background: darken($simple-background-color, 8%); + border-radius: 0 0 4px 4px; + padding: 20px 5px; + padding-bottom: 10px; + overflow: hidden; + display: flex; + flex-wrap: wrap; + z-index: 2; + position: relative; + + @media screen and (max-width: 740px) { + border-radius: 0; + box-shadow: none; + } + + .account-grid-card { + box-sizing: border-box; + width: 335px; + background: $simple-background-color; + border-radius: 4px; + color: $ui-base-color; + margin: 0 5px 10px; + position: relative; + + @media screen and (max-width: 740px) { + width: calc(100% - 10px); + } + + .account-grid-card__header { + overflow: hidden; + height: 100px; + border-radius: 4px 4px 0 0; + background-color: lighten($ui-base-color, 4%); + background-size: cover; + background-position: center; + position: relative; + + &::after { + background: rgba(darken($ui-base-color, 8%), 0.5); + display: block; + content: ""; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + } + } + + .account-grid-card__avatar { + box-sizing: border-box; + padding: 15px; + position: absolute; + z-index: 2; + top: 100px - (40px + 2px); + left: -2px; + } + + .avatar { + width: 80px; + height: 80px; + + img { + display: block; + width: 80px; + height: 80px; + border-radius: 80px; + border: 2px solid $simple-background-color; + background: $simple-background-color; + } + } + + .name { + padding: 15px; + padding-top: 10px; + padding-left: 15px + 80px + 15px; + + a { + display: block; + color: $ui-base-color; + text-decoration: none; + text-overflow: ellipsis; + overflow: hidden; + font-weight: 500; + + &:hover { + .display_name { + text-decoration: underline; + } + } + } + } + + .display_name { + font-size: 16px; + display: block; + text-overflow: ellipsis; + overflow: hidden; + } + + .username { + color: lighten($ui-base-color, 34%); + font-size: 14px; + font-weight: 400; + } + + .note { + padding: 10px 15px; + padding-top: 15px; + box-sizing: border-box; + color: lighten($ui-base-color, 26%); + word-wrap: break-word; + min-height: 80px; + } + } +} + +.nothing-here { + width: 100%; + display: block; + color: $ui-primary-color; + font-size: 14px; + font-weight: 500; + text-align: center; + padding: 60px 0; + padding-top: 55px; + cursor: default; +} + +.account-card { + padding: 14px 10px; + background: $simple-background-color; + border-radius: 4px; + text-align: left; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + + .detailed-status__display-name { + display: block; + overflow: hidden; + margin-bottom: 15px; + + &:last-child { + margin-bottom: 0; + } + + & > div { + float: left; + margin-right: 10px; + width: 48px; + height: 48px; + } + + .avatar { + display: block; + border-radius: 4px; + } + + .display-name { + display: block; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + cursor: default; + + strong { + font-weight: 500; + color: $ui-base-color; + } + + span { + font-size: 14px; + color: $ui-primary-color; + } + } + + &:hover { + .display-name { + strong { + text-decoration: none; + } + } + } + } + + .account__header__content { + font-size: 14px; + color: $ui-base-color; + } +} + +.activity-stream-tabs { + background: $simple-background-color; + border-bottom: 1px solid $ui-secondary-color; + position: relative; + z-index: 2; + + a { + display: inline-block; + padding: 15px; + text-decoration: none; + color: $ui-highlight-color; + text-transform: uppercase; + font-weight: 500; + + &:hover, + &:active, + &:focus { + color: lighten($ui-highlight-color, 8%); + } + + &.active { + color: $ui-base-color; + cursor: default; + } + } +} + +.account-role { + display: inline-block; + padding: 4px 6px; + cursor: default; + border-radius: 3px; + font-size: 12px; + line-height: 12px; + font-weight: 500; + color: $ui-secondary-color; + background-color: rgba($ui-secondary-color, 0.1); + border: 1px solid rgba($ui-secondary-color, 0.5); + + &.moderator { + color: $success-green; + background-color: rgba($success-green, 0.1); + border-color: rgba($success-green, 0.5); + } + + &.admin { + color: lighten($error-red, 12%); + background-color: rgba(lighten($error-red, 12%), 0.1); + border-color: rgba(lighten($error-red, 12%), 0.5); + } +} diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss new file mode 100644 index 0000000000..87bc710af6 --- /dev/null +++ b/app/javascript/styles/mastodon/admin.scss @@ -0,0 +1,349 @@ +.admin-wrapper { + display: flex; + justify-content: center; + height: 100%; + + .sidebar-wrapper { + flex: 1; + height: 100%; + background: $ui-base-color; + display: flex; + justify-content: flex-end; + } + + .sidebar { + width: 240px; + height: 100%; + padding: 0; + overflow-y: auto; + + .logo { + display: block; + margin: 40px auto; + width: 100px; + height: 100px; + } + + ul { + list-style: none; + border-radius: 4px 0 0 4px; + overflow: hidden; + margin-bottom: 20px; + + a { + display: block; + padding: 15px; + color: rgba($primary-text-color, 0.7); + text-decoration: none; + transition: all 200ms linear; + border-radius: 4px 0 0 4px; + + i.fa { + margin-right: 5px; + } + + &:hover { + color: $primary-text-color; + background-color: darken($ui-base-color, 5%); + transition: all 100ms linear; + } + + &.selected { + background: darken($ui-base-color, 2%); + border-radius: 4px 0 0; + } + } + + ul { + background: darken($ui-base-color, 4%); + border-radius: 0 0 0 4px; + margin: 0; + + a { + border: 0; + padding: 15px 35px; + + &.selected { + color: $primary-text-color; + background-color: $ui-highlight-color; + border-bottom: 0; + border-radius: 0; + + &:hover { + background-color: lighten($ui-highlight-color, 5%); + } + } + } + } + } + } + + .content-wrapper { + flex: 2; + overflow: auto; + } + + .content { + max-width: 700px; + padding: 20px 15px; + padding-top: 60px; + padding-left: 25px; + + h2 { + color: $ui-secondary-color; + font-size: 24px; + line-height: 28px; + font-weight: 400; + margin-bottom: 40px; + } + + h3 { + color: $ui-secondary-color; + font-size: 20px; + line-height: 28px; + font-weight: 400; + margin-bottom: 30px; + } + + h6 { + font-size: 16px; + color: $ui-secondary-color; + line-height: 28px; + font-weight: 400; + } + + & > p { + font-size: 14px; + line-height: 18px; + color: $ui-secondary-color; + margin-bottom: 20px; + + strong { + color: $primary-text-color; + font-weight: 500; + } + } + + hr { + margin: 20px 0; + border: 0; + background: transparent; + border-bottom: 1px solid $ui-base-color; + } + + .muted-hint { + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + } + } + + .positive-hint { + color: $valid-value-color; + font-weight: 500; + } + } + + .simple_form { + max-width: 400px; + + &.edit_user, + &.new_form_admin_settings, + &.new_form_two_factor_confirmation, + &.new_form_delete_confirmation, + &.new_import, + &.new_domain_block, + &.edit_domain_block { + max-width: none; + } + + .form_two_factor_confirmation_code, + .form_delete_confirmation_password { + max-width: 400px; + } + + .actions { + max-width: 400px; + } + } + + @media screen and (max-width: 600px) { + display: block; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + + .sidebar-wrapper, + .content-wrapper { + flex: 0 0 auto; + height: auto; + overflow: initial; + } + + .sidebar { + width: 100%; + padding: 10px 0; + height: auto; + + .logo { + margin: 20px auto; + } + } + + .content { + padding-top: 20px; + } + } +} + +.filters { + display: flex; + flex-wrap: wrap; + + .filter-subset { + flex: 0 0 auto; + margin: 0 40px 10px 0; + + &:last-child { + margin-bottom: 20px; + } + + ul { + margin-top: 5px; + list-style: none; + + li { + display: inline-block; + margin-right: 5px; + } + } + + strong { + font-weight: 500; + text-transform: uppercase; + font-size: 12px; + } + + a { + display: inline-block; + color: rgba($primary-text-color, 0.7); + text-decoration: none; + text-transform: uppercase; + font-size: 12px; + font-weight: 500; + border-bottom: 2px solid $ui-base-color; + + &:hover { + color: $primary-text-color; + border-bottom: 2px solid lighten($ui-base-color, 5%); + } + + &.selected { + color: $ui-highlight-color; + border-bottom: 2px solid $ui-highlight-color; + } + } + } +} + +.report-accounts { + display: flex; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.report-accounts__item { + display: flex; + flex: 250px; + flex-direction: column; + margin: 0 5px; + + & > strong { + display: block; + margin: 0 0 10px -5px; + font-weight: 500; + font-size: 14px; + line-height: 18px; + color: $ui-secondary-color; + } + + .account-card { + flex: 1 1 auto; + } +} + +.report-status, +.account-status { + display: flex; + margin-bottom: 10px; + + .activity-stream { + flex: 2 0 0; + margin-right: 20px; + max-width: calc(100% - 60px); + + .entry { + border-radius: 4px; + } + } +} + +.report-status__actions, +.account-status__actions { + flex: 0 0 auto; + display: flex; + flex-direction: column; + + .icon-button { + font-size: 24px; + width: 24px; + text-align: center; + margin-bottom: 10px; + } +} + +.batch-form-box { + display: flex; + flex-wrap: wrap; + margin-bottom: 5px; + + #form_status_batch_action { + margin: 0 5px 5px 0; + font-size: 14px; + } + + input.button { + margin: 0 5px 5px 0; + } + + .media-spoiler-toggle-buttons { + margin-left: auto; + + .button { + overflow: visible; + margin: 0 0 5px 5px; + float: right; + } + } +} + +.batch-checkbox, +.batch-checkbox-all { + display: flex; + align-items: center; + margin-right: 5px; +} + +.back-link { + margin-bottom: 10px; + font-size: 14px; + + a { + color: $classic-highlight-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss new file mode 100644 index 0000000000..b5d77ff63b --- /dev/null +++ b/app/javascript/styles/mastodon/basics.scss @@ -0,0 +1,122 @@ +body { + font-family: 'mastodon-font-sans-serif', sans-serif; + background: $ui-base-color; + background-size: cover; + background-attachment: fixed; + font-size: 13px; + line-height: 18px; + font-weight: 400; + color: $primary-text-color; + padding-bottom: 20px; + text-rendering: optimizelegibility; + font-feature-settings: "kern"; + text-size-adjust: none; + -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-tap-highlight-color: transparent; + + &.system-font { + // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+) + // -apple-system => Safari <11 specific + // BlinkMacSystemFont => Chrome <56 on macOS specific + // Segoe UI => Windows 7/8/10 + // Oxygen => KDE + // Ubuntu => Unity/Ubuntu + // Cantarell => GNOME + // Fira Sans => Firefox OS + // Droid Sans => Older Androids (<4.0) + // Helvetica Neue => Older macOS <10.11 + // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0) + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", mastodon-font-sans-serif, sans-serif; + } + + &.app-body { + position: absolute; + width: 100%; + height: 100%; + padding: 0; + background: $ui-base-color; + } + + &.about-body { + background: darken($ui-base-color, 8%); + padding-bottom: 0; + } + + &.tag-body { + background: darken($ui-base-color, 8%); + padding-bottom: 0; + } + + &.embed { + background: transparent; + margin: 0; + padding-bottom: 0; + + .container { + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; + } + } + + &.admin { + background: darken($ui-base-color, 4%); + position: fixed; + width: 100%; + height: 100%; + padding: 0; + } + + &.error { + position: absolute; + text-align: center; + color: $ui-primary-color; + background: $ui-base-color; + width: 100%; + height: 100%; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + + .dialog { + vertical-align: middle; + margin: 20px; + + img { + display: block; + max-width: 470px; + width: 100%; + height: auto; + margin-top: -120px; + } + + h1 { + font-size: 20px; + line-height: 28px; + font-weight: 400; + } + } + } +} + +button { + font-family: inherit; + cursor: pointer; + + &:focus { + outline: none; + } +} + +.app-holder { + &, + & > div { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + } +} diff --git a/app/javascript/styles/mastodon/boost.scss b/app/javascript/styles/mastodon/boost.scss new file mode 100644 index 0000000000..31053decce --- /dev/null +++ b/app/javascript/styles/mastodon/boost.scss @@ -0,0 +1,18 @@ +@function hex-color($color) { + @if type-of($color) == 'color' { + $color: str-slice(ie-hex-str($color), 4); + } + @return '%23' + unquote($color) +} + +button.icon-button i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,"); + + &:hover { + background-image: url("data:image/svg+xml;utf8,"); + } +} + +button.icon-button.disabled i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,"); +} diff --git a/app/javascript/styles/mastodon/compact_header.scss b/app/javascript/styles/mastodon/compact_header.scss new file mode 100644 index 0000000000..90d98cc8c5 --- /dev/null +++ b/app/javascript/styles/mastodon/compact_header.scss @@ -0,0 +1,34 @@ +.compact-header { + h1 { + font-size: 24px; + line-height: 28px; + color: $ui-primary-color; + font-weight: 500; + margin-bottom: 20px; + padding: 0 10px; + word-wrap: break-word; + + @media screen and (max-width: 740px) { + text-align: center; + padding: 20px 10px 0; + } + + a { + color: inherit; + text-decoration: none; + } + + small { + font-weight: 400; + color: $ui-secondary-color; + } + + img { + display: inline-block; + margin-bottom: -5px; + margin-right: 15px; + width: 36px; + height: 36px; + } + } +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss new file mode 100644 index 0000000000..0ded6f1596 --- /dev/null +++ b/app/javascript/styles/mastodon/components.scss @@ -0,0 +1,4377 @@ +@import 'variables'; + +.app-body { + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; +} + +.button { + background-color: darken($ui-highlight-color, 3%); + border: 10px none; + border-radius: 4px; + box-sizing: border-box; + color: $primary-text-color; + cursor: pointer; + display: inline-block; + font-family: inherit; + font-size: 14px; + font-weight: 500; + height: 36px; + letter-spacing: 0; + line-height: 36px; + overflow: hidden; + padding: 0 16px; + position: relative; + text-align: center; + text-transform: uppercase; + text-decoration: none; + text-overflow: ellipsis; + transition: all 100ms ease-in; + white-space: nowrap; + width: auto; + + &:active, + &:focus, + &:hover { + background-color: lighten($ui-highlight-color, 7%); + transition: all 200ms ease-out; + } + + &:disabled { + background-color: $ui-primary-color; + cursor: default; + } + + &.button-alternative { + font-size: 16px; + line-height: 36px; + height: auto; + color: $ui-base-color; + background: $ui-primary-color; + text-transform: none; + padding: 4px 16px; + + &:active, + &:focus, + &:hover { + background-color: lighten($ui-primary-color, 4%); + } + } + + &.button-secondary { + font-size: 16px; + line-height: 36px; + height: auto; + color: $ui-primary-color; + text-transform: none; + background: transparent; + padding: 3px 15px; + border-radius: 4px; + border: 1px solid $ui-primary-color; + + &:active, + &:focus, + &:hover { + border-color: lighten($ui-primary-color, 4%); + color: lighten($ui-primary-color, 4%); + } + } + + &.button--block { + display: block; + width: 100%; + } +} + +.column__wrapper { + display: flex; + flex: 1 1 auto; + position: relative; +} + +.column-icon { + background: lighten($ui-base-color, 4%); + color: $ui-primary-color; + cursor: pointer; + font-size: 16px; + padding: 15px; + position: absolute; + right: 0; + top: -48px; + z-index: 3; + + &:hover { + color: lighten($ui-primary-color, 7%); + } +} + +.icon-button { + display: inline-block; + padding: 0; + color: $ui-base-lighter-color; + border: none; + background: transparent; + cursor: pointer; + transition: color 100ms ease-in; + + &:hover, + &:active, + &:focus { + color: lighten($ui-base-color, 33%); + transition: color 200ms ease-out; + } + + &.disabled { + color: lighten($ui-base-color, 13%); + cursor: default; + } + + &.active { + color: $ui-highlight-color; + } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &.inverted { + color: lighten($ui-base-color, 33%); + + &:hover, + &:active, + &:focus { + color: $ui-base-lighter-color; + } + + &.disabled { + color: $ui-primary-color; + } + + &.active { + color: $ui-highlight-color; + + &.disabled { + color: lighten($ui-highlight-color, 13%); + } + } + } + + &.overlayed { + box-sizing: content-box; + background: rgba($base-overlay-background, 0.6); + color: rgba($primary-text-color, 0.7); + border-radius: 4px; + padding: 2px; + + &:hover { + background: rgba($base-overlay-background, 0.9); + } + } +} + +.text-icon-button { + color: lighten($ui-base-color, 33%); + border: none; + background: transparent; + cursor: pointer; + font-weight: 600; + font-size: 11px; + padding: 0 3px; + line-height: 27px; + outline: 0; + transition: color 100ms ease-in; + + &:hover, + &:active, + &:focus { + color: $ui-base-lighter-color; + transition: color 200ms ease-out; + } + + &.disabled { + color: lighten($ui-base-color, 13%); + cursor: default; + } + + &.active { + color: $ui-highlight-color; + } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } +} + +.dropdown-menu { + position: absolute; +} + +.dropdown--active .icon-button { + color: $ui-highlight-color; +} + +.dropdown--active::after { + @media screen and (min-width: 631px) { + content: ""; + display: block; + position: absolute; + width: 0; + height: 0; + border-style: solid; + border-width: 0 4.5px 7.8px; + border-color: transparent transparent $ui-secondary-color; + bottom: 8px; + right: 104px; + } +} + +.invisible { + font-size: 0; + line-height: 0; + display: inline-block; + width: 0; + height: 0; + position: absolute; + + img, + svg { + margin: 0 !important; + border: 0 !important; + padding: 0 !important; + width: 0 !important; + height: 0 !important; + } +} + +.ellipsis { + &::after { + content: "…"; + } +} + +.lightbox .icon-button { + color: $ui-base-color; +} + +.compose-form { + padding: 10px; +} + +.compose-form__warning { + color: darken($ui-secondary-color, 65%); + margin-bottom: 15px; + background: $ui-primary-color; + box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3); + padding: 8px 10px; + border-radius: 4px; + font-size: 13px; + font-weight: 400; + + strong { + color: darken($ui-secondary-color, 65%); + font-weight: 500; + } + + a { + color: darken($ui-primary-color, 33%); + font-weight: 500; + text-decoration: underline; + + &:hover, + &:active, + &:focus { + text-decoration: none; + } + } +} + +.compose-form__modifiers { + color: $ui-base-color; + font-family: inherit; + font-size: 14px; + background: $simple-background-color; + border-radius: 0 0 4px; +} + +.compose-form__buttons-wrapper { + display: flex; + justify-content: space-between; +} + +.compose-form__buttons { + padding: 10px; + background: darken($simple-background-color, 8%); + box-shadow: inset 0 5px 5px rgba($base-shadow-color, 0.05); + border-radius: 0 0 4px 4px; + display: flex; + + .icon-button { + box-sizing: content-box; + padding: 0 3px; + } +} + +.compose-form__upload-button-icon { + line-height: 27px; +} + +.compose-form__sensitive-button { + display: none; + + &.compose-form__sensitive-button--visible { + display: block; + } + + .compose-form__sensitive-button__icon { + line-height: 27px; + } +} + +.compose-form__upload-wrapper { + overflow: hidden; +} + +.compose-form__uploads-wrapper { + display: flex; + flex-direction: row; + padding: 5px; + flex-wrap: wrap; +} + +.compose-form__upload { + flex: 1 1 0; + min-width: 40%; + margin: 5px; + + &-description { + position: absolute; + z-index: 2; + bottom: 0; + left: 0; + right: 0; + box-sizing: border-box; + background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent); + padding: 10px; + opacity: 0; + transition: opacity .1s ease; + + input { + background: transparent; + color: $ui-secondary-color; + border: 0; + padding: 0; + margin: 0; + width: 100%; + font-family: inherit; + font-size: 14px; + font-weight: 500; + + &:focus { + color: $white; + } + + &::placeholder { + opacity: 0.54; + color: $ui-secondary-color; + } + } + + &.active { + opacity: 1; + } + } + + .icon-button { + mix-blend-mode: difference; + } +} + +.compose-form__upload-thumbnail { + border-radius: 4px; + background-position: center; + background-size: cover; + background-repeat: no-repeat; + height: 100px; + width: 100%; +} + +.compose-form__label { + display: block; + line-height: 24px; + vertical-align: middle; + + &.with-border { + border-top: 1px solid $ui-base-color; + padding-top: 10px; + } + + .compose-form__label__text { + display: inline-block; + vertical-align: middle; + margin-bottom: 14px; + margin-left: 8px; + color: $ui-primary-color; + } +} + +.compose-form__textarea, +.follow-form__input { + background: $simple-background-color; + + &:disabled { + background: $ui-secondary-color; + } +} + +.compose-form__autosuggest-wrapper { + position: relative; + + .emoji-picker-dropdown { + position: absolute; + right: 5px; + top: 5px; + + ::-webkit-scrollbar-track:hover, + ::-webkit-scrollbar-track:active { + background-color: rgba($base-overlay-background, 0.3); + } + } +} + +.compose-form__publish { + display: flex; + min-width: 0; +} + +.compose-form__publish-button-wrapper { + overflow: hidden; + padding-top: 10px; +} + +.emojione { + display: inline-block; + font-size: inherit; + vertical-align: middle; + object-fit: contain; + margin: -.2ex .15em .2ex; + width: 16px; + height: 16px; + + img { + width: auto; + } +} + +.reply-indicator { + border-radius: 4px 4px 0 0; + position: relative; + bottom: -2px; + background: $ui-primary-color; + padding: 10px; +} + +.reply-indicator__header { + margin-bottom: 5px; + overflow: hidden; +} + +.reply-indicator__cancel { + float: right; + line-height: 24px; +} + +.reply-indicator__display-name { + color: $ui-base-color; + display: block; + max-width: 100%; + line-height: 24px; + overflow: hidden; + padding-right: 25px; + text-decoration: none; +} + +.reply-indicator__display-avatar { + float: left; + margin-right: 5px; +} + +.status__content--with-action { + cursor: pointer; +} + +.status__content, +.reply-indicator__content { + font-size: 15px; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + overflow: hidden; + white-space: pre-wrap; + padding-top: 5px; + + &.status__content--with-spoiler { + white-space: normal; + + .status__content__text { + white-space: pre-wrap; + } + } + + .emojione { + width: 20px; + height: 20px; + margin: -5px 0 0; + } + + p { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: $ui-secondary-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + + .fa { + color: lighten($ui-base-color, 40%); + } + } + + &.mention { + &:hover { + text-decoration: none; + + span { + text-decoration: underline; + } + } + } + + .fa { + color: lighten($ui-base-color, 30%); + } + } + + .status__content__spoiler-link { + background: lighten($ui-base-color, 30%); + + &:hover { + background: lighten($ui-base-color, 33%); + text-decoration: none; + } + } + + .status__content__text { + display: none; + + &.status__content__text--visible { + display: block; + } + } +} + +.status__content__spoiler-link { + display: inline-block; + border-radius: 2px; + background: transparent; + border: 0; + color: lighten($ui-base-color, 8%); + font-weight: 500; + font-size: 11px; + padding: 0 6px; + text-transform: uppercase; + line-height: inherit; + cursor: pointer; +} + +.status__prepend-icon-wrapper { + left: -26px; + position: absolute; +} + +.focusable { + &:focus { + outline: 0; + background: lighten($ui-base-color, 4%); + + .status.status-direct { + background: lighten($ui-base-color, 12%); + } + + .detailed-status, + .detailed-status__action-bar { + background: lighten($ui-base-color, 8%); + } + } +} + +.status { + padding: 8px 10px; + padding-left: 68px; + position: relative; + min-height: 48px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + cursor: default; + + @supports (-ms-overflow-style: -ms-autohiding-scrollbar) { + // Add margin to avoid Edge auto-hiding scrollbar appearing over content. + // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px. + padding-right: 26px; // 10px + 16px + } + + @keyframes fade { + 0% { opacity: 0; } + 100% { opacity: 1; } + } + + opacity: 1; + animation: fade 150ms linear; + + .video-player { + margin-top: 8px; + } + + &.status-direct { + background: lighten($ui-base-color, 8%); + + .icon-button.disabled { + color: lighten($ui-base-color, 16%); + } + } + + &.light { + .status__relative-time { + color: $ui-primary-color; + } + + .status__display-name { + color: $ui-base-color; + } + + .display-name { + strong { + color: $ui-base-color; + } + + span { + color: $ui-primary-color; + } + } + + .status__content { + color: $ui-base-color; + + a { + color: $ui-highlight-color; + } + + a.status__content__spoiler-link { + color: $primary-text-color; + background: $ui-primary-color; + + &:hover { + background: lighten($ui-primary-color, 8%); + } + } + } + } +} + +.notification-favourite { + .status.status-direct { + background: transparent; + + .icon-button.disabled { + color: lighten($ui-base-color, 13%); + } + } +} + +.status__relative-time { + color: $ui-base-lighter-color; + float: right; + font-size: 14px; +} + +.status__display-name { + color: $ui-base-lighter-color; +} + +.status__info .status__display-name { + display: block; + max-width: 100%; + padding-right: 25px; +} + +.status__info { + font-size: 15px; +} + +.status-check-box { + border-bottom: 1px solid $ui-secondary-color; + display: flex; + + .status__content { + flex: 1 1 auto; + padding: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.status-check-box-toggle { + align-items: center; + display: flex; + flex: 0 0 auto; + justify-content: center; + padding: 10px; +} + +.status__prepend { + margin-left: 68px; + color: $ui-base-lighter-color; + padding: 8px 0; + padding-bottom: 2px; + font-size: 14px; + position: relative; + + .status__display-name strong { + color: $ui-base-lighter-color; + } + + > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.status__action-bar { + align-items: center; + display: flex; + margin-top: 5px; +} + +.status__action-bar-button { + float: left; + margin-right: 18px; +} + +.status__action-bar-dropdown { + float: left; + height: 23.15px; + width: 23.15px; +} + +.detailed-status__action-bar-dropdown { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.detailed-status { + background: lighten($ui-base-color, 4%); + padding: 14px 10px; + + .status__content { + font-size: 19px; + line-height: 24px; + + .emojione { + width: 24px; + height: 24px; + margin: -5px 0 0; + } + } + + .video-player { + margin-top: 8px; + } +} + +.detailed-status__meta { + margin-top: 15px; + color: $ui-base-lighter-color; + font-size: 14px; + line-height: 18px; +} + +.detailed-status__action-bar { + background: lighten($ui-base-color, 4%); + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: row; + padding: 10px 0; +} + +.detailed-status__link { + color: inherit; + text-decoration: none; +} + +.detailed-status__favorites, +.detailed-status__reblogs { + display: inline-block; + font-weight: 500; + font-size: 12px; + margin-left: 6px; +} + +.reply-indicator__content { + color: $ui-base-color; + font-size: 14px; + + a { + color: lighten($ui-base-color, 20%); + } +} + +.account { + padding: 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + + .account__display-name { + flex: 1 1 auto; + display: block; + color: $ui-primary-color; + overflow: hidden; + text-decoration: none; + font-size: 14px; + } +} + +.account__wrapper { + display: flex; +} + +.account__avatar-wrapper { + float: left; + margin-left: 12px; + margin-right: 12px; +} + +.account__avatar { + @include avatar-radius(); + position: relative; + cursor: pointer; + + &-inline { + display: inline-block; + vertical-align: middle; + margin-right: 5px; + } +} + +.account__avatar-overlay { + @include avatar-size(48px); + + &-base { + @include avatar-radius(); + @include avatar-size(36px); + } + + &-overlay { + @include avatar-radius(); + @include avatar-size(24px); + + position: absolute; + bottom: 0; + right: 0; + z-index: 1; + } +} + +.account__relationship { + height: 18px; + padding: 10px; + white-space: nowrap; +} + +.account__header { + flex: 0 0 auto; + background: lighten($ui-base-color, 4%); + text-align: center; + background-size: cover; + background-position: center; + position: relative; + + & > div { + background: rgba(lighten($ui-base-color, 4%), 0.9); + padding: 20px 10px; + } + + .account__header__content { + color: $ui-secondary-color; + } + + .account__header__display-name { + color: $primary-text-color; + display: inline-block; + width: 100%; + font-size: 20px; + line-height: 27px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + } + + .account__header__username { + color: $ui-highlight-color; + font-size: 14px; + font-weight: 400; + display: block; + margin-bottom: 10px; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.account__disclaimer { + padding: 10px; + border-top: 1px solid lighten($ui-base-color, 8%); + color: $ui-base-lighter-color; + + strong { + font-weight: 500; + } + + a { + font-weight: 500; + color: inherit; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } +} + +.account__header__content { + color: $ui-primary-color; + font-size: 14px; + font-weight: 400; + overflow: hidden; + word-break: normal; + word-wrap: break-word; + + p { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: inherit; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } +} + +.account__header__display-name { + .emojione { + width: 25px; + height: 25px; + } +} + +.account__action-bar { + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + line-height: 36px; + overflow: hidden; + flex: 0 0 auto; + display: flex; +} + +.account__action-bar-dropdown { + flex: 0 1 calc(50% - 140px); + padding: 10px; + + .dropdown--active { + .dropdown__content.dropdown__right { + left: 6px; + right: initial; + } + + &::after { + bottom: initial; + margin-left: 11px; + margin-top: -7px; + right: initial; + } + } +} + +.account__action-bar-links { + display: flex; + flex: 1 1 auto; + line-height: 18px; +} + +.account__action-bar__tab { + text-decoration: none; + overflow: hidden; + flex: 0 1 80px; + border-left: 1px solid lighten($ui-base-color, 8%); + padding: 10px 5px; + + & > span { + display: block; + text-transform: uppercase; + font-size: 11px; + color: $ui-primary-color; + } + + strong { + display: block; + font-size: 15px; + font-weight: 500; + color: $primary-text-color; + } + + abbr { + color: $ui-base-lighter-color; + } +} + +.account__header__avatar { + background-size: 90px 90px; + display: block; + height: 90px; + margin: 0 auto 10px; + overflow: hidden; + width: 90px; +} + +.account-authorize { + padding: 14px 10px; + + .detailed-status__display-name { + display: block; + margin-bottom: 15px; + overflow: hidden; + } +} + +.account-authorize__avatar { + float: left; + margin-right: 10px; +} + +.status__display-name, +.status__relative-time, +.detailed-status__display-name, +.detailed-status__datetime, +.detailed-status__application, +.account__display-name { + text-decoration: none; +} + +.status__display-name, +.account__display-name { + strong { + color: $primary-text-color; + } +} + +.muted { + .emojione { + opacity: 0.5; + } +} + +.status__display-name, +.reply-indicator__display-name, +.detailed-status__display-name, +.account__display-name { + &:hover strong { + text-decoration: underline; + } +} + +.account__display-name strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.detailed-status__application, +.detailed-status__datetime { + color: inherit; +} + +.detailed-status__display-name { + color: $ui-secondary-color; + display: block; + line-height: 24px; + margin-bottom: 15px; + overflow: hidden; + + strong, + span { + display: block; + text-overflow: ellipsis; + overflow: hidden; + } + + strong { + font-size: 16px; + color: $primary-text-color; + } +} + +.detailed-status__display-avatar { + float: left; + margin-right: 10px; +} + +.status__avatar { + height: 48px; + left: 10px; + position: absolute; + top: 10px; + width: 48px; +} + +.muted { + .status__content p, + .status__content a { + color: $ui-base-lighter-color; + } + + .status__display-name strong { + color: $ui-base-lighter-color; + } + + .status__avatar { + opacity: 0.5; + } + + a.status__content__spoiler-link { + background: $ui-base-lighter-color; + color: lighten($ui-base-color, 4%); + + &:hover { + background: lighten($ui-base-color, 29%); + text-decoration: none; + } + } +} + +.notification__message { + margin-left: 68px; + padding: 8px 0; + padding-bottom: 0; + cursor: default; + color: $ui-primary-color; + font-size: 15px; + position: relative; + + .fa { + color: $ui-highlight-color; + } + + > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.notification__favourite-icon-wrapper { + left: -26px; + position: absolute; + + .star-icon { + color: $gold-star; + } +} + +.star-icon.active { + color: $gold-star; +} + +.notification__display-name { + color: inherit; + font-weight: 500; + text-decoration: none; + + &:hover { + color: $primary-text-color; + text-decoration: underline; + } +} + +.display-name { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.display-name__html { + font-weight: 500; +} + +.display-name__account { + font-size: 14px; +} + +.status__relative-time, +.detailed-status__datetime { + &:hover { + text-decoration: underline; + } +} + +.image-loader { + position: relative; + + &.image-loader--loading { + .image-loader__preview-canvas { + filter: blur(2px); + } + } + + .image-loader__img { + position: absolute; + top: 0; + left: 0; + right: 0; + max-width: 100%; + max-height: 100%; + background-image: none; + } + + &.image-loader--amorphous { + position: static; + + .image-loader__preview-canvas { + display: none; + } + + .image-loader__img { + position: static; + width: auto; + height: auto; + } + } +} + +.navigation-bar { + padding: 10px; + display: flex; + flex-shrink: 0; + cursor: default; + color: $ui-primary-color; + + strong { + color: $primary-text-color; + } + + .permalink { + text-decoration: none; + } + + .icon-button { + pointer-events: none; + opacity: 0; + } +} + +.navigation-bar__profile { + flex: 1 1 auto; + margin-left: 8px; + overflow: hidden; +} + +.navigation-bar__profile-account { + display: block; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; +} + +.navigation-bar__profile-edit { + color: inherit; + text-decoration: none; +} + +.dropdown { + display: inline-block; +} + +.dropdown__content { + display: none; + position: absolute; +} + +.dropdown-menu__separator { + border-bottom: 1px solid darken($ui-secondary-color, 8%); + margin: 5px 7px 6px; + height: 0; +} + +.dropdown-menu { + background: $ui-secondary-color; + padding: 4px 0; + border-radius: 4px; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + + ul { + list-style: none; + } +} + +.dropdown-menu__arrow { + position: absolute; + width: 0; + height: 0; + border: 0 solid transparent; + + &.left { + right: -5px; + margin-top: -5px; + border-width: 5px 0 5px 5px; + border-left-color: $ui-secondary-color; + } + + &.top { + bottom: -5px; + margin-left: -13px; + border-width: 5px 7px 0; + border-top-color: $ui-secondary-color; + } + + &.bottom { + top: -5px; + margin-left: -13px; + border-width: 0 7px 5px; + border-bottom-color: $ui-secondary-color; + } + + &.right { + left: -5px; + margin-top: -5px; + border-width: 5px 5px 5px 0; + border-right-color: $ui-secondary-color; + } +} + +.dropdown-menu__item { + a { + font-size: 13px; + line-height: 18px; + display: block; + padding: 4px 14px; + box-sizing: border-box; + text-decoration: none; + background: $ui-secondary-color; + color: $ui-base-color; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:focus, + &:hover, + &:active { + background: $ui-highlight-color; + color: $ui-secondary-color; + outline: 0; + } + } +} + +.dropdown--active .dropdown__content { + display: block; + line-height: 18px; + max-width: 311px; + right: 0; + text-align: left; + z-index: 9999; + + & > ul { + list-style: none; + background: $ui-secondary-color; + padding: 4px 0; + border-radius: 4px; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.4); + min-width: 140px; + position: relative; + } + + &.dropdown__right { + right: 0; + } + + &.dropdown__left { + & > ul { + left: -98px; + } + } + + & > ul > li > a { + font-size: 13px; + line-height: 18px; + display: block; + padding: 4px 14px; + box-sizing: border-box; + text-decoration: none; + background: $ui-secondary-color; + color: $ui-base-color; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:focus { + outline: 0; + } + + &:hover { + background: $ui-highlight-color; + color: $ui-secondary-color; + } + } +} + +.dropdown__icon { + vertical-align: middle; +} + +.static-content { + padding: 10px; + padding-top: 20px; + color: $ui-base-lighter-color; + + h1 { + font-size: 16px; + font-weight: 500; + margin-bottom: 40px; + text-align: center; + } + + p { + font-size: 13px; + margin-bottom: 20px; + } +} + +.columns-area { + display: flex; + flex: 1 1 auto; + flex-direction: row; + justify-content: flex-start; + overflow-x: auto; + position: relative; +} + +@media screen and (min-width: 360px) { + .columns-area { + padding: 10px; + } + + .react-swipeable-view-container .columns-area { + height: calc(100% - 20px) !important; + } +} + +.react-swipeable-view-container { + &, + .columns-area, + .drawer, + .column { + height: 100%; + } +} + +.react-swipeable-view-container > * { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.column { + width: 330px; + position: relative; + box-sizing: border-box; + display: flex; + flex-direction: column; + + > .scrollable { + background: $ui-base-color; + } +} + +.ui { + flex: 0 0 auto; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: darken($ui-base-color, 7%); +} + +.drawer { + width: 300px; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow-y: hidden; +} + +.drawer__tab { + display: block; + flex: 1 1 auto; + padding: 15px 5px 13px; + color: $ui-primary-color; + text-decoration: none; + text-align: center; + font-size: 16px; + border-bottom: 2px solid transparent; +} + +.column, +.drawer { + flex: 1 1 100%; + overflow: hidden; +} + +@media screen and (min-width: 360px) { + .tabs-bar { + margin: 10px; + margin-bottom: 0; + } + + .search { + margin-bottom: 10px; + } +} + +@media screen and (max-width: 630px) { + .column, + .drawer { + width: 100%; + padding: 0; + } + + .columns-area { + flex-direction: column; + } + + .search__input, + .autosuggest-textarea__textarea { + font-size: 16px; + } +} + +@media screen and (min-width: 631px) { + .columns-area { + padding: 0; + } + + .column, + .drawer { + flex: 0 0 auto; + padding: 10px; + padding-left: 5px; + padding-right: 5px; + + &:first-child { + padding-left: 10px; + } + + &:last-child { + padding-right: 10px; + } + } + + .columns-area > div { + .column, + .drawer { + padding-left: 5px; + padding-right: 5px; + } + } +} + +.drawer__pager { + box-sizing: border-box; + padding: 0; + flex-grow: 1; + position: relative; + overflow: hidden; + display: flex; +} + +.drawer__inner { + position: absolute; + top: 0; + left: 0; + background: lighten($ui-base-color, 13%); + box-sizing: border-box; + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; + overflow-y: auto; + width: 100%; + height: 100%; + + &.darker { + background: $ui-base-color; + } +} + +.pseudo-drawer { + background: lighten($ui-base-color, 13%); + font-size: 13px; + text-align: left; +} + +.drawer__header { + flex: 0 0 auto; + font-size: 16px; + background: lighten($ui-base-color, 8%); + margin-bottom: 10px; + display: flex; + flex-direction: row; + + a { + transition: background 100ms ease-in; + + &:hover { + background: lighten($ui-base-color, 3%); + transition: background 200ms ease-out; + } + } +} + +.tabs-bar { + display: flex; + background: lighten($ui-base-color, 8%); + flex: 0 0 auto; + overflow-y: auto; +} + +.tabs-bar__link { + display: block; + flex: 1 1 auto; + padding: 15px 10px; + color: $primary-text-color; + text-decoration: none; + text-align: center; + font-size: 14px; + font-weight: 500; + border-bottom: 2px solid lighten($ui-base-color, 8%); + transition: all 200ms linear; + + .fa { + font-weight: 400; + font-size: 16px; + } + + &.active { + border-bottom: 2px solid $ui-highlight-color; + color: $ui-highlight-color; + } + + &:hover, + &:focus, + &:active { + @media screen and (min-width: 631px) { + background: lighten($ui-base-color, 14%); + transition: all 100ms linear; + } + } + + span { + margin-left: 5px; + display: none; + } +} + +@media screen and (min-width: 600px) { + .tabs-bar__link { + span { + display: inline; + } + } +} + +@media screen and (min-width: 631px) { + .tabs-bar { + display: none; + } +} + +.scrollable { + overflow-y: scroll; + overflow-x: hidden; + flex: 1 1 auto; + -webkit-overflow-scrolling: touch; + will-change: transform; // improves perf in mobile Chrome + + &.optionally-scrollable { + overflow-y: auto; + } + + @supports(display: grid) { // hack to fix Chrome <57 + contain: strict; + } +} + +.scrollable.fullscreen { + @supports(display: grid) { // hack to fix Chrome <57 + contain: none; + } +} + +.column-back-button { + background: lighten($ui-base-color, 4%); + color: $ui-highlight-color; + cursor: pointer; + flex: 0 0 auto; + font-size: 16px; + border: 0; + text-align: unset; + padding: 15px; + margin: 0; + z-index: 3; + + &:hover { + text-decoration: underline; + } +} + +.column-header__back-button { + background: lighten($ui-base-color, 4%); + border: 0; + font-family: inherit; + color: $ui-highlight-color; + cursor: pointer; + flex: 0 0 auto; + font-size: 16px; + padding: 0 5px 0 0; + z-index: 3; + + &:hover { + text-decoration: underline; + } + + &:last-child { + padding: 0 15px 0 0; + } +} + +.column-back-button__icon { + display: inline-block; + margin-right: 5px; +} + +.column-back-button--slim { + position: relative; +} + +.column-back-button--slim-button { + cursor: pointer; + flex: 0 0 auto; + font-size: 16px; + padding: 15px; + position: absolute; + right: 0; + top: -48px; +} + +.react-toggle { + display: inline-block; + position: relative; + cursor: pointer; + background-color: transparent; + border: 0; + padding: 0; + user-select: none; + -webkit-tap-highlight-color: rgba($base-overlay-background, 0); + -webkit-tap-highlight-color: transparent; +} + +.react-toggle-screenreader-only { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +.react-toggle--disabled { + cursor: not-allowed; + opacity: 0.5; + transition: opacity 0.25s; +} + +.react-toggle-track { + width: 50px; + height: 24px; + padding: 0; + border-radius: 30px; + background-color: $ui-base-color; + transition: all 0.2s ease; +} + +.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { + background-color: darken($ui-base-color, 10%); +} + +.react-toggle--checked .react-toggle-track { + background-color: $ui-highlight-color; +} + +.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { + background-color: lighten($ui-highlight-color, 10%); +} + +.react-toggle-track-check { + position: absolute; + width: 14px; + height: 10px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + line-height: 0; + left: 8px; + opacity: 0; + transition: opacity 0.25s ease; +} + +.react-toggle--checked .react-toggle-track-check { + opacity: 1; + transition: opacity 0.25s ease; +} + +.react-toggle-track-x { + position: absolute; + width: 10px; + height: 10px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + line-height: 0; + right: 10px; + opacity: 1; + transition: opacity 0.25s ease; +} + +.react-toggle--checked .react-toggle-track-x { + opacity: 0; +} + +.react-toggle-thumb { + transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms; + position: absolute; + top: 1px; + left: 1px; + width: 22px; + height: 22px; + border: 1px solid $ui-base-color; + border-radius: 50%; + background-color: darken($simple-background-color, 2%); + box-sizing: border-box; + transition: all 0.25s ease; +} + +.react-toggle--checked .react-toggle-thumb { + left: 27px; + border-color: $ui-highlight-color; +} + +.column-link { + background: lighten($ui-base-color, 8%); + color: $primary-text-color; + display: block; + font-size: 16px; + padding: 15px; + text-decoration: none; + + &:hover { + background: lighten($ui-base-color, 11%); + } +} + +.column-link__icon { + display: inline-block; + margin-right: 5px; +} + +.column-subheading { + background: $ui-base-color; + color: $ui-base-lighter-color; + padding: 8px 20px; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + cursor: default; +} + +.autosuggest-textarea, +.spoiler-input { + position: relative; +} + +.autosuggest-textarea__textarea, +.spoiler-input__input { + display: block; + box-sizing: border-box; + width: 100%; + margin: 0; + color: $ui-base-color; + background: $simple-background-color; + padding: 10px; + font-family: inherit; + font-size: 14px; + resize: vertical; + border: 0; + outline: 0; + + &:focus { + outline: 0; + } + + @media screen and (max-width: 600px) { + font-size: 16px; + } +} + +.spoiler-input__input { + border-radius: 4px; +} + +.autosuggest-textarea__textarea { + min-height: 100px; + border-radius: 4px 4px 0 0; + padding-bottom: 0; + padding-right: 10px + 22px; + resize: none; + + @media screen and (max-width: 600px) { + height: 100px !important; // prevent auto-resize textarea + resize: vertical; + } +} + +.autosuggest-textarea__suggestions { + box-sizing: border-box; + display: none; + position: absolute; + top: 100%; + width: 100%; + z-index: 99; + box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4); + background: $ui-secondary-color; + border-radius: 0 0 4px 4px; + color: $ui-base-color; + font-size: 14px; + padding: 6px; + + &.autosuggest-textarea__suggestions--visible { + display: block; + } +} + +.autosuggest-textarea__suggestions__item { + padding: 10px; + cursor: pointer; + border-radius: 4px; + + &:hover, + &:focus, + &:active, + &.selected { + background: darken($ui-secondary-color, 10%); + } +} + +.autosuggest-account, +.autosuggest-emoji { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + line-height: 18px; + font-size: 14px; +} + +.autosuggest-account-icon, +.autosuggest-emoji img { + display: block; + margin-right: 8px; + width: 16px; + height: 16px; +} + +.autosuggest-account .display-name__account { + color: lighten($ui-base-color, 36%); +} + +.character-counter__wrapper { + line-height: 36px; + margin: 0 16px 0 8px; + padding-top: 10px; +} + +.character-counter { + cursor: default; + font-size: 16px; +} + +.character-counter--over { + color: $warning-red; +} + +.getting-started__wrapper { + position: relative; + overflow-y: auto; +} + +.getting-started__footer { + display: flex; + flex-direction: column; +} + +.getting-started { + box-sizing: border-box; + padding-bottom: 235px; + background: url('../images/mastodon-getting-started.png') no-repeat 0 100%; + flex: 1 0 auto; + + p { + color: $ui-secondary-color; + } + + a { + color: $ui-base-lighter-color; + } +} + +.setting-text { + color: $ui-primary-color; + background: transparent; + border: none; + border-bottom: 2px solid $ui-primary-color; + box-sizing: border-box; + display: block; + font-family: inherit; + margin-bottom: 10px; + padding: 7px 0; + width: 100%; + + &:focus, + &:active { + color: $primary-text-color; + border-bottom-color: $ui-highlight-color; + } + + @media screen and (max-width: 600px) { + font-size: 16px; + } + + &.light { + color: $ui-base-color; + border-bottom: 2px solid lighten($ui-base-color, 27%); + + &:focus, + &:active { + color: $ui-base-color; + border-bottom-color: $ui-highlight-color; + } + } +} + +@import 'boost'; + +button.icon-button i.fa-retweet { + background-position: 0 0; + height: 19px; + transition: background-position 0.9s steps(10); + transition-duration: 0s; + vertical-align: middle; + width: 22px; + + &::before { + display: none !important; + } +} + +button.icon-button.active i.fa-retweet { + transition-duration: 0.9s; + background-position: 0 100%; +} + +.status-card { + display: flex; + cursor: pointer; + font-size: 14px; + border: 1px solid lighten($ui-base-color, 8%); + border-radius: 4px; + color: $ui-base-lighter-color; + margin-top: 14px; + text-decoration: none; + overflow: hidden; + + &:hover { + background: lighten($ui-base-color, 8%); + } +} + +.status-card-video, +.status-card-rich, +.status-card-photo { + margin-top: 14px; + overflow: hidden; + + iframe { + width: 100%; + height: auto; + } +} + +.status-card-photo { + display: block; + text-decoration: none; + + img { + display: block; + width: 100%; + height: auto; + margin: 0; + } +} + +.status-card-video { + iframe { + width: 100%; + height: 100%; + } +} + +.status-card__title { + display: block; + font-weight: 500; + margin-bottom: 5px; + color: $ui-primary-color; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-card__content { + flex: 1 1 auto; + overflow: hidden; + padding: 14px 14px 14px 8px; +} + +.status-card__description { + color: $ui-primary-color; +} + +.status-card__host { + display: block; + margin-top: 5px; + font-size: 13px; +} + +.status-card__image { + flex: 0 0 100px; + background: lighten($ui-base-color, 8%); +} + +.status-card.horizontal { + display: block; + + .status-card__image { + width: 100%; + } + + .status-card__image-image { + border-radius: 4px 4px 0 0; + } +} + +.status-card__image-image { + border-radius: 4px 0 0 4px; + display: block; + height: auto; + margin: 0; + width: 100%; +} + +.load-more { + display: block; + color: $ui-base-lighter-color; + background-color: transparent; + border: 0; + font-size: inherit; + text-align: center; + line-height: inherit; + margin: 0; + padding: 15px; + width: 100%; + clear: both; + + &:hover { + background: lighten($ui-base-color, 2%); + } +} + +.missing-indicator { + text-align: center; + font-size: 16px; + font-weight: 500; + color: lighten($ui-base-color, 16%); + background: $ui-base-color; + cursor: default; + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + + & > div { + background: url('../images/mastodon-not-found.png') no-repeat center -50px; + padding-top: 210px; + width: 100%; + } +} + +.column-header__wrapper { + position: relative; + flex: 0 0 auto; + + &.active { + &::before { + display: block; + content: ""; + position: absolute; + top: 35px; + left: 0; + right: 0; + margin: 0 auto; + width: 60%; + pointer-events: none; + height: 28px; + z-index: 1; + background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%); + } + } +} + +.column-header { + display: flex; + padding: 15px; + font-size: 16px; + background: lighten($ui-base-color, 4%); + flex: 0 0 auto; + cursor: pointer; + position: relative; + z-index: 2; + outline: 0; + + &.active { + box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3); + + .column-header__icon { + color: $ui-highlight-color; + text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4); + } + } + + &:focus, + &:active { + outline: 0; + } +} + +.column-header__buttons { + height: 48px; + display: flex; + margin: -15px; + margin-left: 0; +} + +.column-header__button { + background: lighten($ui-base-color, 4%); + border: 0; + color: $ui-primary-color; + cursor: pointer; + font-size: 16px; + padding: 0 15px; + + &:hover { + color: lighten($ui-primary-color, 7%); + } + + &.active { + color: $primary-text-color; + background: lighten($ui-base-color, 8%); + + &:hover { + color: $primary-text-color; + background: lighten($ui-base-color, 8%); + } + } +} + +.column-header__collapsible { + max-height: 70vh; + overflow: hidden; + overflow-y: auto; + color: $ui-primary-color; + transition: max-height 150ms ease-in-out, opacity 300ms linear; + opacity: 1; + + &.collapsed { + max-height: 0; + opacity: 0.5; + } + + &.animating { + overflow-y: hidden; + } +} + +.column-header__collapsible-inner { + background: lighten($ui-base-color, 8%); + padding: 15px; +} + +.column-header__setting-btn { + &:hover { + color: lighten($ui-primary-color, 4%); + text-decoration: underline; + } +} + +.column-header__setting-arrows { + float: right; + + .column-header__setting-btn { + padding: 0 10px; + + &:last-child { + padding-right: 0; + } + } +} + +.column-header__title { + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex: 1; +} + +.text-btn { + display: inline-block; + padding: 0; + font-family: inherit; + font-size: inherit; + color: inherit; + border: 0; + background: transparent; + cursor: pointer; +} + +.column-header__icon { + display: inline-block; + margin-right: 5px; +} + +.loading-indicator { + color: lighten($ui-base-color, 26%); + font-size: 12px; + font-weight: 400; + text-transform: uppercase; + overflow: visible; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + span { + display: block; + float: left; + margin-left: 50%; + transform: translateX(-50%); + margin: 82px 0 0 50%; + white-space: nowrap; + animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000); + } +} + +.loading-indicator__figure { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 0; + height: 0; + box-sizing: border-box; + border: 0 solid lighten($ui-base-color, 26%); + border-radius: 50%; + animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000); +} + +@keyframes loader-figure { + 0% { + width: 0; + height: 0; + background-color: lighten($ui-base-color, 26%); + } + + 29% { + background-color: lighten($ui-base-color, 26%); + } + + 30% { + width: 42px; + height: 42px; + background-color: transparent; + border-width: 21px; + opacity: 1; + } + + 100% { + width: 42px; + height: 42px; + border-width: 0; + opacity: 0; + background-color: transparent; + } +} + +@keyframes loader-label { + 0% { opacity: 0.25; } + 30% { opacity: 1; } + 100% { opacity: 0.25; } +} + +.video-error-cover { + align-items: center; + background: $base-overlay-background; + color: $primary-text-color; + cursor: pointer; + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + margin-top: 8px; + position: relative; + text-align: center; + z-index: 100; +} + +.media-spoiler { + background: $base-overlay-background; + color: $ui-primary-color; + border: 0; + width: 100%; + height: 100%; + + &:hover, + &:active, + &:focus { + color: lighten($ui-primary-color, 8%); + } +} + +.media-spoiler__warning { + display: block; + font-size: 14px; +} + +.media-spoiler__trigger { + display: block; + font-size: 11px; + font-weight: 500; +} + +.spoiler-button { + display: none; + left: 4px; + position: absolute; + text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; + top: 4px; + z-index: 100; + + &.spoiler-button--visible { + display: block; + } +} + +.modal-container--preloader { + background: lighten($ui-base-color, 8%); +} + +.account--panel { + background: lighten($ui-base-color, 4%); + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: row; + padding: 10px 0; +} + +.account--panel__button, +.detailed-status__button { + flex: 1 1 auto; + text-align: center; +} + +.column-settings__outer { + background: lighten($ui-base-color, 8%); + padding: 15px; +} + +.column-settings__section { + color: $ui-primary-color; + cursor: default; + display: block; + font-weight: 500; + margin-bottom: 10px; +} + +.column-settings__row { + .text-btn { + margin-bottom: 15px; + } +} + +.modal-container__nav { + align-items: center; + background: rgba($base-overlay-background, 0.5); + box-sizing: border-box; + border: 0; + color: $primary-text-color; + cursor: pointer; + display: flex; + font-size: 24px; + height: 100%; + padding: 30px 15px; + position: absolute; + top: 0; +} + +.modal-container__nav--left { + left: -61px; +} + +.modal-container__nav--right { + right: -61px; +} + +.account--follows-info { + color: $primary-text-color; + position: absolute; + top: 10px; + left: 10px; + opacity: 0.7; + display: inline-block; + vertical-align: top; + background-color: rgba($base-overlay-background, 0.4); + text-transform: uppercase; + font-size: 11px; + font-weight: 500; + padding: 4px; + border-radius: 4px; +} + +.account--action-button { + position: absolute; + top: 10px; + right: 20px; +} + +.setting-toggle { + display: block; + line-height: 24px; +} + +.setting-toggle__label, +.setting-meta__label { + color: $ui-primary-color; + display: inline-block; + margin-bottom: 14px; + margin-left: 8px; + vertical-align: middle; +} + +.setting-meta__label { + color: $ui-primary-color; + float: right; +} + +.empty-column-indicator, +.error-column { + color: lighten($ui-base-color, 20%); + background: $ui-base-color; + text-align: center; + padding: 20px; + font-size: 15px; + font-weight: 400; + cursor: default; + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + @supports(display: grid) { // hack to fix Chrome <57 + contain: strict; + } + + a { + color: $ui-highlight-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.error-column { + flex-direction: column; +} + +@keyframes heartbeat { + from { + transform: scale(1); + transform-origin: center center; + animation-timing-function: ease-out; + } + + 10% { + transform: scale(0.91); + animation-timing-function: ease-in; + } + + 17% { + transform: scale(0.98); + animation-timing-function: ease-out; + } + + 33% { + transform: scale(0.87); + animation-timing-function: ease-in; + } + + 45% { + transform: scale(1); + animation-timing-function: ease-out; + } +} + +.pulse-loading { + animation: heartbeat 1.5s ease-in-out infinite both; +} + +.emoji-picker-dropdown__menu { + background: $simple-background-color; + position: absolute; + box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4); + border-radius: 4px; + margin-top: 5px; + + .emoji-mart-scroll { + transition: opacity 200ms ease; + } + + &.selecting .emoji-mart-scroll { + opacity: 0.5; + } +} + +.emoji-picker-dropdown__modifiers { + position: absolute; + top: 60px; + right: 11px; + cursor: pointer; +} + +.emoji-picker-dropdown__modifiers__menu { + position: absolute; + z-index: 4; + top: -4px; + left: -8px; + background: $simple-background-color; + border-radius: 4px; + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2); + overflow: hidden; + + button { + display: block; + cursor: pointer; + border: 0; + padding: 4px 8px; + background: transparent; + + &:hover, + &:focus, + &:active { + background: rgba($ui-secondary-color, 0.4); + } + } + + .emoji-mart-emoji { + height: 22px; + } +} + +.emoji-mart-emoji { + span { + background-repeat: no-repeat; + } +} + +.upload-area { + align-items: center; + background: rgba($base-overlay-background, 0.8); + display: flex; + height: 100%; + justify-content: center; + left: 0; + opacity: 0; + position: absolute; + top: 0; + visibility: hidden; + width: 100%; + z-index: 2000; + + * { + pointer-events: none; + } +} + +.upload-area__drop { + width: 320px; + height: 160px; + display: flex; + box-sizing: border-box; + position: relative; + padding: 8px; +} + +.upload-area__background { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: -1; + border-radius: 4px; + background: $ui-base-color; + box-shadow: 0 0 5px rgba($base-shadow-color, 0.2); +} + +.upload-area__content { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: $ui-secondary-color; + font-size: 18px; + font-weight: 500; + border: 2px dashed $ui-base-lighter-color; + border-radius: 4px; +} + +.upload-progress { + padding: 10px; + color: $ui-base-lighter-color; + overflow: hidden; + display: flex; + + .fa { + font-size: 34px; + margin-right: 10px; + } + + span { + font-size: 12px; + text-transform: uppercase; + font-weight: 500; + display: block; + } +} + +.upload-progess__message { + flex: 1 1 auto; +} + +.upload-progress__backdrop { + width: 100%; + height: 6px; + border-radius: 6px; + background: $ui-base-lighter-color; + position: relative; + margin-top: 5px; +} + +.upload-progress__tracker { + position: absolute; + left: 0; + top: 0; + height: 6px; + background: $ui-highlight-color; + border-radius: 6px; +} + +.emoji-button { + display: block; + font-size: 24px; + line-height: 24px; + margin-left: 2px; + width: 24px; + outline: 0; + cursor: pointer; + + &:active, + &:focus { + outline: 0 !important; + } + + img { + filter: grayscale(100%); + opacity: 0.8; + display: block; + margin: 0; + width: 22px; + height: 22px; + margin-top: 2px; + } + + &:hover, + &:active, + &:focus { + img { + opacity: 1; + filter: none; + } + } +} + +.dropdown--active .emoji-button img { + opacity: 1; + filter: none; +} + +.privacy-dropdown__dropdown { + position: absolute; + background: $simple-background-color; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + border-radius: 4px; + margin-left: 40px; + overflow: hidden; +} + +.privacy-dropdown__option { + color: $ui-base-color; + padding: 10px; + cursor: pointer; + display: flex; + + &:hover, + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + + .privacy-dropdown__option__content { + color: $primary-text-color; + + strong { + color: $primary-text-color; + } + } + } + + &.active:hover { + background: lighten($ui-highlight-color, 4%); + } +} + +.privacy-dropdown__option__icon { + display: flex; + align-items: center; + justify-content: center; + margin-right: 10px; +} + +.privacy-dropdown__option__content { + flex: 1 1 auto; + color: darken($ui-primary-color, 24%); + + strong { + font-weight: 500; + display: block; + color: $ui-base-color; + } +} + +.privacy-dropdown.active { + .privacy-dropdown__value { + background: $simple-background-color; + border-radius: 4px 4px 0 0; + box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1); + + .icon-button { + transition: none; + } + + &.active { + background: $ui-highlight-color; + + .icon-button { + color: $primary-text-color; + } + } + } + + .privacy-dropdown__dropdown { + display: block; + box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1); + } +} + +.search { + position: relative; +} + +.search__input { + outline: 0; + box-sizing: border-box; + display: block; + width: 100%; + border: none; + padding: 10px; + padding-right: 30px; + font-family: inherit; + background: $ui-base-color; + color: $ui-primary-color; + font-size: 14px; + margin: 0; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + + @media screen and (max-width: 600px) { + font-size: 16px; + } +} + +.search__icon { + .fa { + position: absolute; + top: 10px; + right: 10px; + z-index: 2; + display: inline-block; + opacity: 0; + transition: all 100ms linear; + font-size: 18px; + width: 18px; + height: 18px; + color: $ui-secondary-color; + cursor: default; + pointer-events: none; + + &.active { + pointer-events: auto; + opacity: 0.3; + } + } + + .fa-search { + transform: rotate(90deg); + + &.active { + pointer-events: none; + transform: rotate(0deg); + } + } + + .fa-times-circle { + top: 11px; + transform: rotate(0deg); + cursor: pointer; + + &.active { + transform: rotate(90deg); + } + + &:hover { + color: $primary-text-color; + } + } +} + +.search-results__header { + color: $ui-base-lighter-color; + background: lighten($ui-base-color, 2%); + border-bottom: 1px solid darken($ui-base-color, 4%); + padding: 15px 10px; + font-size: 14px; + font-weight: 500; +} + +.search-results__hashtag { + display: block; + padding: 10px; + color: $ui-secondary-color; + text-decoration: none; + + &:hover, + &:active, + &:focus { + color: lighten($ui-secondary-color, 4%); + text-decoration: underline; + } +} + +.modal-root { + transition: opacity 0.3s linear; + will-change: opacity; + z-index: 9999; +} + +.modal-root__overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba($base-overlay-background, 0.7); +} + +.modal-root__container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + align-content: space-around; + z-index: 9999; + pointer-events: none; + user-select: none; +} + +.modal-root__modal { + pointer-events: auto; + display: flex; + z-index: 9999; +} + +.media-modal { + max-width: 80vw; + max-height: 80vh; + position: relative; + + .extended-video-player, + img, + canvas, + video { + max-width: 80vw; + max-height: 80vh; + width: auto; + height: auto; + margin: auto; + } + + .extended-video-player, + video { + display: flex; + width: 80vw; + height: 80vh; + } + + img, + canvas { + display: block; + background: url('../images/void.png') repeat; + object-fit: contain; + } + + .react-swipeable-view-container { + max-width: 80vw; + } +} + +.media-modal__content { + background: $base-overlay-background; +} + +.media-modal__pagination { + width: 100%; + text-align: center; + position: absolute; + left: 0; + bottom: -40px; +} + +.media-modal__page-dot { + display: inline-block; +} + +.media-modal__button { + background-color: $white; + height: 12px; + width: 12px; + border-radius: 6px; + margin: 10px; + padding: 0; + border: 0; + font-size: 0; +} + +.media-modal__button--active { + background-color: $ui-highlight-color; +} + +.media-modal__close { + position: absolute; + right: 4px; + top: 4px; + z-index: 100; +} + +.onboarding-modal, +.error-modal, +.embed-modal { + background: $ui-secondary-color; + color: $ui-base-color; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.onboarding-modal__pager { + height: 80vh; + width: 80vw; + max-width: 520px; + max-height: 420px; + + .react-swipeable-view-container > div { + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 25px; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + display: flex; + user-select: text; + } +} + +.error-modal__body { + height: 80vh; + width: 80vw; + max-width: 520px; + max-height: 420px; + position: relative; + + & > div { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 25px; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + display: flex; + opacity: 0; + user-select: text; + } +} + +.error-modal__body { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; +} + +@media screen and (max-width: 550px) { + .onboarding-modal { + width: 100%; + height: 100%; + border-radius: 0; + } + + .onboarding-modal__pager { + width: 100%; + height: auto; + max-width: none; + max-height: none; + flex: 1 1 auto; + } +} + +.onboarding-modal__paginator, +.error-modal__footer { + flex: 0 0 auto; + background: darken($ui-secondary-color, 8%); + display: flex; + padding: 25px; + + & > div { + min-width: 33px; + } + + .onboarding-modal__nav, + .error-modal__nav { + color: darken($ui-secondary-color, 34%); + background-color: transparent; + border: 0; + font-size: 14px; + font-weight: 500; + padding: 0; + line-height: inherit; + height: auto; + + &:hover, + &:focus, + &:active { + color: darken($ui-secondary-color, 38%); + } + + &.onboarding-modal__done, + &.onboarding-modal__next { + color: $ui-highlight-color; + } + } +} + +.error-modal__footer { + justify-content: center; +} + +.onboarding-modal__dots { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.onboarding-modal__dot { + width: 14px; + height: 14px; + border-radius: 14px; + background: darken($ui-secondary-color, 16%); + margin: 0 3px; + cursor: pointer; + + &:hover { + background: darken($ui-secondary-color, 18%); + } + + &.active { + cursor: default; + background: darken($ui-secondary-color, 24%); + } +} + +.onboarding-modal__page__wrapper { + pointer-events: none; + + &.onboarding-modal__page__wrapper--active { + pointer-events: auto; + } +} + +.onboarding-modal__page { + cursor: default; + line-height: 21px; + + h1 { + font-size: 18px; + font-weight: 500; + color: $ui-base-color; + margin-bottom: 20px; + } + + a { + color: $ui-highlight-color; + + &:hover, + &:focus, + &:active { + color: lighten($ui-highlight-color, 4%); + } + } + + p { + font-size: 16px; + color: lighten($ui-base-color, 8%); + margin-top: 10px; + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + + strong { + font-weight: 500; + background: $ui-base-color; + color: $ui-secondary-color; + border-radius: 4px; + font-size: 14px; + padding: 3px 6px; + } + } +} + +.onboarding-modal__page-one { + display: flex; + align-items: center; +} + +.onboarding-modal__page-one__elephant-friend { + background: url('../images/elephant-friend-1.png') no-repeat center center / contain; + width: 155px; + height: 193px; + margin-right: 15px; +} + +@media screen and (max-width: 400px) { + .onboarding-modal__page-one { + flex-direction: column; + align-items: normal; + } + + .onboarding-modal__page-one__elephant-friend { + width: 100%; + height: 30vh; + max-height: 160px; + margin-bottom: 5vh; + } +} + +.onboarding-modal__page-two, +.onboarding-modal__page-three, +.onboarding-modal__page-four, +.onboarding-modal__page-five { + p { + text-align: left; + } + + .figure { + background: darken($ui-base-color, 8%); + color: $ui-secondary-color; + margin-bottom: 20px; + border-radius: 4px; + padding: 10px; + text-align: center; + font-size: 14px; + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3); + + .onboarding-modal__image { + border-radius: 4px; + margin-bottom: 10px; + } + + &.non-interactive { + pointer-events: none; + text-align: left; + } + } +} + +.onboarding-modal__page-four__columns { + .row { + display: flex; + margin-bottom: 20px; + + & > div { + flex: 1 1 0; + margin: 0 10px; + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + + p { + text-align: center; + } + } + + &:last-child { + margin-bottom: 0; + } + } + + .column-header { + color: $primary-text-color; + } +} + +@media screen and (max-width: 320px) and (max-height: 600px) { + .onboarding-modal__page p { + font-size: 14px; + line-height: 20px; + } + + .onboarding-modal__page-two .figure, + .onboarding-modal__page-three .figure, + .onboarding-modal__page-four .figure, + .onboarding-modal__page-five .figure { + font-size: 12px; + margin-bottom: 10px; + } + + .onboarding-modal__page-four__columns .row { + margin-bottom: 10px; + } + + .onboarding-modal__page-four__columns .column-header { + padding: 5px; + font-size: 12px; + } +} + +.onboarding-modal__image { + border-radius: 8px; + width: 70vw; + max-width: 450px; + max-height: auto; + display: block; + margin: auto; + margin-bottom: 20px; +} + +.onboard-sliders { + display: inline-block; + max-width: 30px; + max-height: auto; + margin-left: 10px; +} + +.boost-modal, +.confirmation-modal, +.report-modal, +.actions-modal, +.mute-modal { + background: lighten($ui-secondary-color, 8%); + color: $ui-base-color; + border-radius: 8px; + overflow: hidden; + max-width: 90vw; + width: 480px; + position: relative; + flex-direction: column; + + .status__display-name { + display: block; + max-width: 100%; + padding-right: 25px; + } + + .status__avatar { + height: 28px; + left: 10px; + position: absolute; + top: 10px; + width: 48px; + } +} + +.actions-modal { + .status { + background: $white; + border-bottom-color: $ui-secondary-color; + padding-top: 10px; + padding-bottom: 10px; + } + + .dropdown-menu__separator { + border-bottom-color: $ui-secondary-color; + } +} + +.boost-modal__container { + overflow-x: scroll; + padding: 10px; + + .status { + user-select: text; + border-bottom: 0; + } +} + +.boost-modal__action-bar, +.confirmation-modal__action-bar, +.mute-modal__action-bar, +.report-modal__action-bar { + display: flex; + justify-content: space-between; + background: $ui-secondary-color; + padding: 10px; + line-height: 36px; + + & > div { + flex: 1 1 auto; + text-align: right; + color: lighten($ui-base-color, 33%); + padding-right: 10px; + } + + .button { + flex: 0 0 auto; + } +} + +.boost-modal__status-header { + font-size: 15px; +} + +.boost-modal__status-time { + float: right; + font-size: 14px; +} + +.confirmation-modal { + max-width: 85vw; + + @media screen and (min-width: 480px) { + max-width: 380px; + } +} + +.mute-modal { + line-height: 24px; +} + +.mute-modal .react-toggle { + vertical-align: middle; +} + +.report-modal__statuses, +.report-modal__comment { + padding: 10px; +} + +.report-modal__statuses { + min-height: 20vh; + max-height: 40vh; + overflow-y: auto; + overflow-x: hidden; +} + +.report-modal__comment { + .setting-text { + margin-top: 10px; + } +} + +.actions-modal { + .status { + overflow-y: auto; + max-height: 300px; + } + + max-height: 80vh; + max-width: 80vw; + + .actions-modal__item-label { + font-weight: 500; + } + + ul { + overflow-y: auto; + flex-shrink: 0; + + li:empty { + margin: 0; + } + + li:not(:empty) { + a { + color: $ui-base-color; + display: flex; + padding: 12px 16px; + font-size: 15px; + align-items: center; + text-decoration: none; + + &, + button { + transition: none; + } + + &.active, + &:hover, + &:active, + &:focus { + &, + button { + background: $ui-highlight-color; + color: $primary-text-color; + } + } + + button:first-child { + margin-right: 10px; + } + } + } + } +} + +.confirmation-modal__action-bar, +.mute-modal__action-bar { + .confirmation-modal__cancel-button, + .mute-modal__cancel-button { + background-color: transparent; + color: darken($ui-secondary-color, 34%); + font-size: 14px; + font-weight: 500; + + &:hover, + &:focus, + &:active { + color: darken($ui-secondary-color, 38%); + } + } +} + +.confirmation-modal__container, +.mute-modal__container, +.report-modal__target { + padding: 30px; + font-size: 16px; + text-align: center; + + strong { + font-weight: 500; + } +} + +.loading-bar { + background-color: $ui-highlight-color; + height: 3px; + position: absolute; + top: 0; + left: 0; +} + +.media-gallery__gifv__label { + display: block; + position: absolute; + color: $primary-text-color; + background: rgba($base-overlay-background, 0.5); + bottom: 6px; + left: 6px; + padding: 2px 6px; + border-radius: 2px; + font-size: 11px; + font-weight: 600; + z-index: 1; + pointer-events: none; + opacity: 0.9; + transition: opacity 0.1s ease; +} + +.media-gallery__gifv { + &.autoplay { + .media-gallery__gifv__label { + display: none; + } + } + + &:hover { + .media-gallery__gifv__label { + opacity: 1; + } + } +} + +.attachment-list { + display: flex; + font-size: 14px; + border: 1px solid lighten($ui-base-color, 8%); + border-radius: 4px; + margin-top: 14px; + overflow: hidden; +} + +.attachment-list__icon { + flex: 0 0 auto; + color: $ui-base-lighter-color; + padding: 8px 18px; + cursor: default; + border-right: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 26px; + + .fa { + display: block; + } +} + +.attachment-list__list { + list-style: none; + padding: 4px 0; + padding-left: 8px; + display: flex; + flex-direction: column; + justify-content: center; + + li { + display: block; + padding: 4px 0; + } + + a { + text-decoration: none; + color: $ui-base-lighter-color; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } +} + +/* Media Gallery */ +.media-gallery { + box-sizing: border-box; + margin-top: 8px; + overflow: hidden; + position: relative; + width: 100%; +} + +.media-gallery__item { + border: none; + box-sizing: border-box; + display: block; + float: left; + position: relative; + + &.standalone { + .media-gallery__item-gifv-thumbnail { + transform: none; + } + } +} + +.media-gallery__item-thumbnail { + cursor: zoom-in; + display: block; + text-decoration: none; + height: 100%; + line-height: 0; + + &, + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.media-gallery__gifv { + height: 100%; + overflow: hidden; + position: relative; + width: 100%; +} + +.media-gallery__item-gifv-thumbnail { + cursor: zoom-in; + height: 100%; + object-fit: cover; + position: relative; + top: 50%; + transform: translateY(-50%); + width: 100%; + z-index: 1; +} + +.media-gallery__item-thumbnail-label { + clip: rect(1px 1px 1px 1px); /* IE6, IE7 */ + clip: rect(1px, 1px, 1px, 1px); + overflow: hidden; + position: absolute; +} +/* End Media Gallery */ + +/* Status Video Player */ +.status__video-player { + background: $base-overlay-background; + box-sizing: border-box; + cursor: default; /* May not be needed */ + margin-top: 8px; + overflow: hidden; + position: relative; +} + +.status__video-player-video { + height: 100%; + object-fit: cover; + position: relative; + top: 50%; + transform: translateY(-50%); + width: 100%; + z-index: 1; +} + +.status__video-player-expand, +.status__video-player-mute { + color: $primary-text-color; + opacity: 0.8; + position: absolute; + right: 4px; + text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; +} + +.status__video-player-spoiler { + display: none; + color: $primary-text-color; + left: 4px; + position: absolute; + text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; + top: 4px; + z-index: 100; + + &.status__video-player-spoiler--visible { + display: block; + } +} + +.status__video-player-expand { + bottom: 4px; + z-index: 100; +} + +.status__video-player-mute { + top: 4px; + z-index: 5; +} + +.video-player { + overflow: hidden; + position: relative; + background: $base-shadow-color; + max-width: 100%; + + video { + height: 100%; + width: 100%; + z-index: 1; + } + + &.fullscreen { + width: 100% !important; + height: 100% !important; + margin: 0; + + video { + max-width: 100% !important; + max-height: 100% !important; + } + } + + &.inline { + video { + object-fit: cover; + position: relative; + top: 50%; + transform: translateY(-50%); + } + } + + &__controls { + position: absolute; + z-index: 2; + bottom: 0; + left: 0; + right: 0; + box-sizing: border-box; + background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 60%, transparent); + padding: 0 10px; + opacity: 0; + transition: opacity .1s ease; + + &.active { + opacity: 1; + } + } + + &.inactive { + video, + .video-player__controls { + visibility: hidden; + } + } + + &__spoiler { + display: none; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 4; + border: 0; + background: $base-shadow-color; + color: $ui-primary-color; + transition: none; + pointer-events: none; + + &.active { + display: block; + pointer-events: auto; + + &:hover, + &:active, + &:focus { + color: lighten($ui-primary-color, 8%); + } + } + + &__title { + display: block; + font-size: 14px; + } + + &__subtitle { + display: block; + font-size: 11px; + font-weight: 500; + } + } + + &__buttons { + padding-bottom: 10px; + font-size: 16px; + + &.left { + float: left; + + button { + padding-right: 10px; + } + } + + &.right { + float: right; + + button { + padding-left: 10px; + } + } + + button { + background: transparent; + padding: 0; + border: 0; + color: $white; + + &:active, + &:hover, + &:focus { + color: $ui-highlight-color; + } + } + } + + &__seek { + cursor: pointer; + height: 24px; + position: relative; + + &::before { + content: ""; + width: 100%; + background: rgba($white, 0.35); + display: block; + position: absolute; + height: 4px; + top: 10px; + } + + &__progress, + &__buffer { + display: block; + position: absolute; + height: 4px; + top: 10px; + background: $ui-highlight-color; + } + + &__buffer { + background: rgba($white, 0.2); + } + + &__handle { + position: absolute; + z-index: 3; + opacity: 0; + border-radius: 50%; + width: 12px; + height: 12px; + top: 6px; + margin-left: -6px; + transition: opacity .1s ease; + background: $ui-highlight-color; + pointer-events: none; + + &.active { + opacity: 1; + } + } + + &:hover { + .video-player__seek__handle { + opacity: 1; + } + } + } +} + +.media-spoiler-video { + background-size: cover; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + margin-top: 8px; + position: relative; + border: 0; + display: block; +} + +.media-spoiler-video-play-icon { + border-radius: 100px; + color: rgba($primary-text-color, 0.8); + font-size: 36px; + left: 50%; + padding: 5px; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); +} +/* End Video Player */ + +.account-gallery__container { + margin: -2px; + padding: 4px; + display: flex; + flex-wrap: wrap; +} + +.account-gallery__item { + flex: 1 1 auto; + width: calc(100% / 3 - 4px); + height: 95px; + margin: 2px; + + a { + display: block; + width: 100%; + height: 100%; + background-color: $base-overlay-background; + background-size: cover; + background-position: center; + position: relative; + color: inherit; + text-decoration: none; + + &:hover, + &:active, + &:focus { + outline: 0; + } + } +} + +.account-section-headline { + color: $ui-base-lighter-color; + background: lighten($ui-base-color, 2%); + border-bottom: 1px solid lighten($ui-base-color, 4%); + padding: 15px 10px; + font-size: 14px; + font-weight: 500; + position: relative; + cursor: default; + + &::before, + &::after { + display: block; + content: ""; + position: absolute; + bottom: 0; + left: 18px; + width: 0; + height: 0; + border-style: solid; + border-width: 0 10px 10px; + border-color: transparent transparent lighten($ui-base-color, 4%); + } + + &::after { + bottom: -1px; + border-color: transparent transparent $ui-base-color; + } +} + +::-webkit-scrollbar-thumb { + border-radius: 0; +} + +.search-popout { + background: $simple-background-color; + border-radius: 4px; + padding: 10px 14px; + padding-bottom: 14px; + margin-top: 10px; + color: $ui-primary-color; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + + h4 { + text-transform: uppercase; + color: $ui-primary-color; + font-size: 13px; + font-weight: 500; + margin-bottom: 10px; + } + + li { + padding: 4px 0; + } + + ul { + margin-bottom: 10px; + } + + em { + font-weight: 500; + color: $ui-base-color; + } +} + +noscript { + text-align: center; + + img { + width: 200px; + opacity: 0.5; + animation: flicker 4s infinite; + } + + div { + font-size: 14px; + margin: 30px auto; + color: $ui-secondary-color; + max-width: 400px; + + a { + color: $ui-highlight-color; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + } +} + +@keyframes flicker { + 0% { opacity: 1; } + 30% { opacity: 0.75; } + 100% { opacity: 1; } +} + +@media screen and (max-width: 630px) and (max-height: 400px) { + $duration: 400ms; + $delay: 100ms; + + .tabs-bar, + .search { + will-change: margin-top; + transition: margin-top $duration $delay; + } + + .navigation-bar { + will-change: padding-bottom; + transition: padding-bottom $duration $delay; + } + + .navigation-bar { + & > a:first-child { + will-change: margin-top, margin-left, width; + transition: margin-top $duration $delay, margin-left $duration ($duration + $delay); + } + + & > .navigation-bar__profile-edit { + will-change: margin-top; + transition: margin-top $duration $delay; + } + + & > .icon-button { + will-change: opacity; + transition: opacity $duration $delay; + } + } + + .is-composing { + .tabs-bar, + .search { + margin-top: -50px; + } + + .navigation-bar { + padding-bottom: 0; + + & > a:first-child { + margin-top: -50px; + margin-left: -40px; + } + + .navigation-bar__profile { + padding-top: 2px; + } + + .navigation-bar__profile-edit { + position: absolute; + margin-top: -50px; + } + + .icon-button { + pointer-events: auto; + opacity: 1; + } + } + } +} + +.embed-modal { + max-width: 80vw; + max-height: 80vh; + + h4 { + padding: 30px; + font-weight: 500; + font-size: 16px; + text-align: center; + } + + .embed-modal__container { + padding: 10px; + + .hint { + margin-bottom: 15px; + } + + .embed-modal__html { + color: $ui-secondary-color; + outline: 0; + box-sizing: border-box; + display: block; + width: 100%; + border: none; + padding: 10px; + font-family: 'mastodon-font-monospace', monospace; + background: $ui-base-color; + color: $ui-primary-color; + font-size: 14px; + margin: 0; + margin-bottom: 15px; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + + @media screen and (max-width: 600px) { + font-size: 16px; + } + } + + .embed-modal__iframe { + width: 400px; + max-width: 100%; + overflow: hidden; + border: 0; + } + } +} diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss new file mode 100644 index 0000000000..af2589e23c --- /dev/null +++ b/app/javascript/styles/mastodon/containers.scss @@ -0,0 +1,116 @@ +.container { + width: 700px; + margin: 0 auto; + margin-top: 40px; + + @media screen and (max-width: 740px) { + width: 100%; + margin: 0; + } +} + +.logo-container { + margin: 100px auto; + margin-bottom: 50px; + + @media screen and (max-width: 400px) { + margin: 30px auto; + margin-bottom: 20px; + } + + h1 { + display: flex; + justify-content: center; + align-items: center; + + img { + height: 42px; + margin-right: 10px; + } + + a { + display: flex; + justify-content: center; + align-items: center; + color: $primary-text-color; + text-decoration: none; + outline: 0; + padding: 12px 16px; + line-height: 32px; + font-family: 'mastodon-font-display', sans-serif; + font-weight: 500; + font-size: 14px; + } + } +} + +.compose-standalone { + .compose-form { + width: 400px; + margin: 0 auto; + padding: 20px 0; + margin-top: 40px; + box-sizing: border-box; + + @media screen and (max-width: 400px) { + width: 100%; + margin-top: 0; + padding: 20px; + } + } +} + +.account-header { + width: 400px; + margin: 0 auto; + display: flex; + font-size: 13px; + line-height: 18px; + box-sizing: border-box; + padding: 20px 0; + padding-bottom: 0; + margin-bottom: -30px; + margin-top: 40px; + + @media screen and (max-width: 440px) { + width: 100%; + margin: 0; + margin-bottom: 10px; + padding: 20px; + padding-bottom: 0; + } + + .avatar { + width: 40px; + height: 40px; + margin-right: 8px; + + img { + width: 100%; + height: 100%; + display: block; + margin: 0; + border-radius: 4px; + } + } + + .name { + flex: 1 1 auto; + color: $ui-secondary-color; + width: calc(100% - 88px); + + .username { + display: block; + font-weight: 500; + text-overflow: ellipsis; + overflow: hidden; + } + } + + .logout-link { + display: block; + font-size: 32px; + line-height: 40px; + margin-left: 8px; + } +} diff --git a/app/javascript/styles/mastodon/emoji_picker.scss b/app/javascript/styles/mastodon/emoji_picker.scss new file mode 100644 index 0000000000..2b46d30fce --- /dev/null +++ b/app/javascript/styles/mastodon/emoji_picker.scss @@ -0,0 +1,199 @@ +.emoji-mart { + &, + * { + box-sizing: border-box; + line-height: 1.15; + } + + font-size: 13px; + display: inline-block; + color: $ui-base-color; + + .emoji-mart-emoji { + padding: 6px; + } +} + +.emoji-mart-bar { + border: 0 solid darken($ui-secondary-color, 8%); + + &:first-child { + border-bottom-width: 1px; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + background: $ui-secondary-color; + } + + &:last-child { + border-top-width: 1px; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + display: none; + } +} + +.emoji-mart-anchors { + display: flex; + justify-content: space-between; + padding: 0 6px; + color: $ui-primary-color; + line-height: 0; +} + +.emoji-mart-anchor { + position: relative; + flex: 1; + text-align: center; + padding: 12px 4px; + overflow: hidden; + transition: color .1s ease-out; + cursor: pointer; + + &:hover { + color: darken($ui-primary-color, 4%); + } +} + +.emoji-mart-anchor-selected { + color: darken($ui-highlight-color, 3%); + + &:hover { + color: darken($ui-highlight-color, 3%); + } + + .emoji-mart-anchor-bar { + bottom: 0; + } +} + +.emoji-mart-anchor-bar { + position: absolute; + bottom: -3px; + left: 0; + width: 100%; + height: 3px; + background-color: darken($ui-highlight-color, 3%); +} + +.emoji-mart-anchors { + i { + display: inline-block; + width: 100%; + max-width: 22px; + } + + svg { + fill: currentColor; + max-height: 18px; + } +} + +.emoji-mart-scroll { + overflow-y: scroll; + height: 270px; + max-height: 35vh; + padding: 0 6px 6px; + background: $simple-background-color; + will-change: transform; +} + +.emoji-mart-search { + padding: 10px; + padding-right: 45px; + background: $simple-background-color; + + input { + font-size: 14px; + font-weight: 400; + padding: 7px 9px; + font-family: inherit; + display: block; + width: 100%; + background: rgba($ui-secondary-color, 0.3); + color: $ui-primary-color; + border: 1px solid $ui-secondary-color; + border-radius: 4px; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + } +} + +.emoji-mart-category .emoji-mart-emoji { + cursor: pointer; + + span { + z-index: 1; + position: relative; + text-align: center; + } + + &:hover::before { + z-index: 0; + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba($ui-secondary-color, 0.7); + border-radius: 100%; + } +} + +.emoji-mart-category-label { + z-index: 2; + position: relative; + position: -webkit-sticky; + position: sticky; + top: 0; + + span { + display: block; + width: 100%; + font-weight: 500; + padding: 5px 6px; + background: $simple-background-color; + } +} + +.emoji-mart-emoji { + position: relative; + display: inline-block; + font-size: 0; + + span { + width: 22px; + height: 22px; + } +} + +.emoji-mart-no-results { + font-size: 14px; + text-align: center; + padding-top: 70px; + color: $ui-primary-color; + + .emoji-mart-category-label { + display: none; + } + + .emoji-mart-no-results-label { + margin-top: .2em; + } + + .emoji-mart-emoji:hover::before { + content: none; + } +} + +.emoji-mart-preview { + display: none; +} diff --git a/app/javascript/styles/mastodon/footer.scss b/app/javascript/styles/mastodon/footer.scss new file mode 100644 index 0000000000..2d953b34e7 --- /dev/null +++ b/app/javascript/styles/mastodon/footer.scss @@ -0,0 +1,30 @@ +.footer { + text-align: center; + margin-top: 30px; + font-size: 12px; + color: darken($ui-secondary-color, 25%); + + .domain { + font-weight: 500; + + a { + color: inherit; + text-decoration: none; + } + } + + .powered-by, + .single-user-login { + font-weight: 400; + + a { + color: inherit; + text-decoration: underline; + font-weight: 500; + + &:hover { + text-decoration: none; + } + } + } +} diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss new file mode 100644 index 0000000000..61fcf286ff --- /dev/null +++ b/app/javascript/styles/mastodon/forms.scss @@ -0,0 +1,540 @@ +code { + font-family: 'mastodon-font-monospace', monospace; + font-weight: 400; +} + +.form-container { + max-width: 400px; + padding: 20px; + margin: 0 auto; +} + +.simple_form { + .input { + margin-bottom: 15px; + overflow: hidden; + } + + span.hint { + display: block; + color: $ui-primary-color; + font-size: 12px; + margin-top: 4px; + } + + h4 { + text-transform: uppercase; + font-size: 13px; + font-weight: 500; + color: $ui-primary-color; + padding-bottom: 8px; + margin-bottom: 8px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + p.hint { + margin-bottom: 15px; + color: $ui-primary-color; + + &.subtle-hint { + text-align: center; + font-size: 12px; + line-height: 18px; + margin-top: 15px; + margin-bottom: 0; + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + } + } + } + + .card { + margin-bottom: 15px; + } + + strong { + font-weight: 500; + } + + .label_input { + display: flex; + + label { + flex: 0 0 auto; + } + + input { + flex: 1 1 auto; + } + } + + .input.with_label { + padding: 15px 0; + margin-bottom: 0; + + .label_input { + flex-wrap: wrap; + align-items: flex-start; + } + + &.select .label_input { + align-items: initial; + } + + .label_input > label { + font-family: inherit; + font-size: 16px; + color: $primary-text-color; + display: block; + padding-top: 5px; + margin-bottom: 5px; + flex: 1; + min-width: 150px; + word-wrap: break-word; + + &.select { + flex: 0; + } + + & ~ * { + margin-left: 10px; + } + } + + ul { + flex: 390px; + } + + &.boolean { + padding: initial; + margin-bottom: initial; + + .label_input > label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: block; + width: auto; + } + + label.checkbox { + position: relative; + padding-left: 25px; + flex: 1 1 auto; + } + } + } + + .input.with_block_label { + & > label { + font-family: inherit; + font-size: 16px; + color: $primary-text-color; + display: block; + padding-top: 5px; + } + + .hint { + margin-bottom: 15px; + } + + li { + float: left; + width: 50%; + } + } + + .fields-group { + margin-bottom: 25px; + } + + .input.radio_buttons .radio label { + margin-bottom: 5px; + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: block; + width: auto; + } + + .input.boolean { + margin-bottom: 5px; + + label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: block; + width: auto; + } + + label.checkbox { + position: relative; + padding-left: 25px; + flex: 1 1 auto; + } + + input[type=checkbox] { + position: absolute; + left: 0; + top: 5px; + margin: 0; + } + + .hint { + padding-left: 25px; + margin-left: 0; + } + } + + .check_boxes { + .checkbox { + label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: block; + width: auto; + position: relative; + padding-top: 5px; + padding-left: 25px; + flex: 1 1 auto; + } + + input[type=checkbox] { + position: absolute; + left: 0; + top: 5px; + margin: 0; + } + } + } + + input[type=text], + input[type=number], + input[type=email], + input[type=password], + textarea { + background: transparent; + box-sizing: border-box; + border: 0; + border-bottom: 2px solid $ui-primary-color; + border-radius: 2px 2px 0 0; + padding: 7px 4px; + font-size: 16px; + color: $primary-text-color; + display: block; + width: 100%; + outline: 0; + font-family: inherit; + resize: vertical; + + &:invalid { + box-shadow: none; + } + + &:focus:invalid { + border-bottom-color: $error-value-color; + } + + &:required:valid { + border-bottom-color: $valid-value-color; + } + + &:active, + &:focus { + border-bottom-color: $ui-highlight-color; + background: rgba($base-overlay-background, 0.1); + } + } + + .input.field_with_errors { + label { + color: $error-value-color; + } + + input[type=text], + input[type=email], + input[type=password] { + border-bottom-color: $error-value-color; + } + + .error { + display: block; + font-weight: 500; + color: $error-value-color; + margin-top: 4px; + } + } + + .actions { + margin-top: 30px; + display: flex; + } + + button, + .button, + .block-button { + display: block; + width: 100%; + border: 0; + border-radius: 4px; + background: $ui-highlight-color; + color: $primary-text-color; + font-size: 18px; + line-height: inherit; + height: auto; + padding: 10px; + text-transform: uppercase; + text-decoration: none; + text-align: center; + box-sizing: border-box; + cursor: pointer; + font-weight: 500; + outline: 0; + margin-bottom: 10px; + margin-right: 10px; + + &:last-child { + margin-right: 0; + } + + &:hover { + background-color: lighten($ui-highlight-color, 5%); + } + + &:active, + &:focus { + background-color: darken($ui-highlight-color, 5%); + } + + &.negative { + background: $error-value-color; + + &:hover { + background-color: lighten($error-value-color, 5%); + } + + &:active, + &:focus { + background-color: darken($error-value-color, 5%); + } + } + } + + select { + font-size: 16px; + max-height: 29px; + } + + .input-with-append { + position: relative; + + .input input { + padding-right: 127px; + } + + .append { + position: absolute; + right: 0; + top: 0; + padding: 7px 4px; + padding-bottom: 9px; + font-size: 16px; + color: $ui-base-lighter-color; + font-family: inherit; + pointer-events: none; + cursor: default; + } + } +} + +.flash-message { + background: lighten($ui-base-color, 8%); + color: $ui-primary-color; + border-radius: 4px; + padding: 15px 10px; + margin-bottom: 30px; + box-shadow: 0 0 5px rgba($base-shadow-color, 0.2); + text-align: center; + + p { + margin-bottom: 15px; + } + + .oauth-code { + color: $ui-secondary-color; + outline: 0; + box-sizing: border-box; + display: block; + width: 100%; + border: none; + padding: 10px; + font-family: 'mastodon-font-monospace', monospace; + background: $ui-base-color; + color: $ui-primary-color; + font-size: 14px; + margin: 0; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + } + + strong { + font-weight: 500; + } + + @media screen and (max-width: 740px) and (min-width: 441px) { + margin-top: 40px; + } +} + +.form-footer { + margin-top: 30px; + text-align: center; + + a { + color: $ui-primary-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.oauth-prompt, +.follow-prompt { + margin-bottom: 30px; + text-align: center; + color: $ui-primary-color; + + h2 { + font-size: 16px; + margin-bottom: 30px; + } + + strong { + color: $ui-secondary-color; + font-weight: 500; + } + + @media screen and (max-width: 740px) and (min-width: 441px) { + margin-top: 40px; + } +} + +.qr-wrapper { + display: flex; + flex-wrap: wrap; + align-items: flex-start; +} + +.qr-code { + flex: 0 0 auto; + background: $simple-background-color; + padding: 4px; + margin: 0 10px 20px 0; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + display: inline-block; + + svg { + display: block; + margin: 0; + } +} + +.qr-alternative { + margin-bottom: 20px; + color: $ui-secondary-color; + flex: 150px; + + samp { + display: block; + font-size: 14px; + } +} + +.table-form { + p { + margin-bottom: 15px; + + strong { + font-weight: 500; + } + } +} + +.simple_form, +.table-form { + .warning { + box-sizing: border-box; + background: rgba($error-value-color, 0.5); + color: $primary-text-color; + text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3); + box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4); + border-radius: 4px; + padding: 10px; + margin-bottom: 15px; + + a { + color: $primary-text-color; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + + strong { + font-weight: 600; + display: block; + margin-bottom: 5px; + + .fa { + font-weight: 400; + } + } + } +} + +.action-pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + + .actions, + .pagination { + flex: 1 1 auto; + } + + .actions { + padding: 30px 0; + padding-right: 20px; + flex: 0 0 auto; + } +} + +.post-follow-actions { + text-align: center; + color: $ui-primary-color; + + div { + margin-bottom: 4px; + } +} diff --git a/app/javascript/styles/mastodon/landing_strip.scss b/app/javascript/styles/mastodon/landing_strip.scss new file mode 100644 index 0000000000..0bf9daafd4 --- /dev/null +++ b/app/javascript/styles/mastodon/landing_strip.scss @@ -0,0 +1,36 @@ +.landing-strip, +.memoriam-strip { + background: rgba(darken($ui-base-color, 7%), 0.8); + color: $ui-primary-color; + font-weight: 400; + padding: 14px; + border-radius: 4px; + margin-bottom: 20px; + display: flex; + align-items: center; + + strong, + a { + font-weight: 500; + } + + a { + color: inherit; + text-decoration: underline; + } + + .logo { + width: 30px; + height: 30px; + flex: 0 0 auto; + margin-right: 15px; + } + + @media screen and (max-width: 740px) { + margin-bottom: 0; + } +} + +.memoriam-strip { + background: rgba($base-shadow-color, 0.7); +} diff --git a/app/javascript/styles/mastodon/lists.scss b/app/javascript/styles/mastodon/lists.scss new file mode 100644 index 0000000000..6019cd8002 --- /dev/null +++ b/app/javascript/styles/mastodon/lists.scss @@ -0,0 +1,19 @@ +.no-list { + list-style: none; + + li { + display: inline-block; + margin: 0 5px; + } +} + +.recovery-codes { + list-style: none; + margin: 0 auto; + + li { + font-size: 125%; + line-height: 1.5; + letter-spacing: 1px; + } +} diff --git a/app/javascript/styles/mastodon/reset.scss b/app/javascript/styles/mastodon/reset.scss new file mode 100644 index 0000000000..cc5ba9d7c8 --- /dev/null +++ b/app/javascript/styles/mastodon/reset.scss @@ -0,0 +1,91 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} + +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} + +body { + line-height: 1; +} + +ol, ul { + list-style: none; +} + +blockquote, q { + quotes: none; +} + +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-thumb { + background: lighten($ui-base-color, 4%); + border: 0px none $base-border-color; + border-radius: 50px; +} + +::-webkit-scrollbar-thumb:hover { + background: lighten($ui-base-color, 6%); +} + +::-webkit-scrollbar-thumb:active { + background: lighten($ui-base-color, 4%); +} + +::-webkit-scrollbar-track { + border: 0px none $base-border-color; + border-radius: 0; + background: rgba($base-overlay-background, 0.1); +} + +::-webkit-scrollbar-track:hover { + background: $ui-base-color; +} + +::-webkit-scrollbar-track:active { + background: $ui-base-color; +} + +::-webkit-scrollbar-corner { + background: transparent; +} diff --git a/app/javascript/styles/mastodon/rtl.scss b/app/javascript/styles/mastodon/rtl.scss new file mode 100644 index 0000000000..67bfa8a385 --- /dev/null +++ b/app/javascript/styles/mastodon/rtl.scss @@ -0,0 +1,254 @@ +body.rtl { + direction: rtl; + + .column-link__icon, + .column-header__icon { + margin-right: 0; + margin-left: 5px; + } + + .character-counter__wrapper { + margin-right: 8px; + margin-left: 16px; + } + + .navigation-bar__profile { + margin-left: 0; + margin-right: 8px; + } + + .search__input { + padding-right: 10px; + padding-left: 30px; + } + + .search__icon .fa { + right: auto; + left: 10px; + } + + .column-header__buttons { + left: 0; + right: auto; + } + + .column-header__back-button { + padding-left: 5px; + padding-right: 0; + } + + .column-header__setting-arrows { + float: left; + } + + .compose-form__modifiers { + border-radius: 0 0 0 4px; + } + + .setting-toggle { + margin-left: 0; + margin-right: 8px; + } + + .setting-meta__label { + float: left; + } + + .status__avatar { + left: auto; + right: 10px; + } + + .status, + .activity-stream .status.light { + padding-left: 10px; + padding-right: 68px; + } + + .status__info .status__display-name, + .activity-stream .status.light .status__display-name { + padding-left: 25px; + padding-right: 0; + } + + .activity-stream .pre-header { + padding-right: 68px; + padding-left: 0; + } + + .status__prepend { + margin-left: 0; + margin-right: 68px; + } + + .status__prepend-icon-wrapper { + left: auto; + right: -26px; + } + + .activity-stream .pre-header .pre-header__icon { + left: auto; + right: 42px; + } + + .account__avatar-overlay-overlay { + right: auto; + left: 0; + } + + .column-back-button--slim-button { + right: auto; + left: 0; + } + + .status__relative-time, + .activity-stream .status.light .status__header .status__meta { + float: left; + } + + .activity-stream .detailed-status.light .detailed-status__display-name > div { + float: right; + margin-right: 0; + margin-left: 10px; + } + + .activity-stream .detailed-status.light .detailed-status__meta span > span { + margin-left: 0; + margin-right: 6px; + } + + .status__action-bar-button { + float: right; + margin-right: 0; + margin-left: 18px; + } + + .status__action-bar-dropdown { + float: right; + } + + .privacy-dropdown__dropdown { + margin-left: 0; + margin-right: 40px; + } + + .privacy-dropdown__option__icon { + margin-left: 10px; + margin-right: 0; + } + + .detailed-status__display-avatar { + margin-right: 0; + margin-left: 10px; + float: right; + } + + .detailed-status__favorites, + .detailed-status__reblogs { + margin-left: 0; + margin-right: 6px; + } + + .fa-ul { + margin-left: 0; + margin-left: 2.14285714em; + } + + .fa-li { + left: auto; + right: -2.14285714em; + } + + .admin-wrapper .sidebar ul a i.fa, + a.table-action-link i.fa { + margin-right: 0; + margin-left: 5px; + } + + .simple_form .check_boxes .checkbox label, + .simple_form .input.with_label.boolean label.checkbox { + padding-left: 0; + padding-right: 25px; + } + + .simple_form .check_boxes .checkbox input[type="checkbox"], + .simple_form .input.boolean input[type="checkbox"] { + left: auto; + right: 0; + } + + .simple_form .input-with-append .input input { + padding-left: 127px; + padding-right: 0; + } + + .simple_form .input-with-append .append { + right: auto; + left: 0; + } + + .table th, + .table td { + text-align: right; + } + + .filters .filter-subset { + margin-right: 0; + margin-left: 45px; + } + + .landing-page .header-wrapper .mascot { + right: 60px; + left: auto; + } + + .landing-page .header .hero .floats .float-1 { + left: -120px; + right: auto; + } + + .landing-page .header .hero .floats .float-2 { + left: 210px; + right: auto; + } + + .landing-page .header .hero .floats .float-3 { + left: 110px; + right: auto; + } + + .landing-page .header .links .brand img { + left: 0; + } + + .landing-page .fa-external-link { + padding-right: 5px; + padding-left: 0 !important; + } + + .landing-page .features #mastodon-timeline { + margin-right: 0; + margin-left: 30px; + } + + @media screen and (min-width: 631px) { + .column, + .drawer { + padding-left: 5px; + padding-right: 5px; + + &:first-child { + padding-left: 5px; + padding-right: 10px; + } + } + + .columns-area > div { + .column, + .drawer { + padding-left: 5px; + padding-right: 5px; + } + } + } +} diff --git a/app/javascript/styles/mastodon/stream_entries.scss b/app/javascript/styles/mastodon/stream_entries.scss new file mode 100644 index 0000000000..4f323a3787 --- /dev/null +++ b/app/javascript/styles/mastodon/stream_entries.scss @@ -0,0 +1,339 @@ +.activity-stream { + clear: both; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + + .entry { + background: $simple-background-color; + + .detailed-status.light, + .status.light { + border-bottom: 1px solid $ui-secondary-color; + animation: none; + } + + &:last-child { + &, + .detailed-status.light, + .status.light { + border-bottom: 0; + border-radius: 0 0 4px 4px; + } + } + + &:first-child { + &, + .detailed-status.light, + .status.light { + border-radius: 4px 4px 0 0; + } + + &:last-child { + &, + .detailed-status.light, + .status.light { + border-radius: 4px; + } + } + } + + @media screen and (max-width: 740px) { + &, + .detailed-status.light, + .status.light { + border-radius: 0 !important; + } + } + } + + &.with-header { + .entry { + &:first-child { + &, + .detailed-status.light, + .status.light { + border-radius: 0; + } + + &:last-child { + &, + .detailed-status.light, + .status.light { + border-radius: 0 0 4px 4px; + } + } + } + } + } + + .status.light { + padding: 14px 14px 14px (48px + 14px * 2); + position: relative; + min-height: 48px; + cursor: default; + + .status__header { + font-size: 15px; + + .status__meta { + float: right; + font-size: 14px; + + .status__relative-time { + color: $ui-primary-color; + } + } + } + + .status__display-name { + display: block; + max-width: 100%; + padding-right: 25px; + color: $ui-base-color; + } + + .status__avatar { + position: absolute; + left: 14px; + top: 14px; + width: 48px; + height: 48px; + + & > div { + width: 48px; + height: 48px; + } + + img { + display: block; + border-radius: 4px; + } + } + + .display-name { + display: block; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + strong { + font-weight: 500; + color: $ui-base-color; + } + + span { + font-size: 14px; + color: $ui-primary-color; + } + } + + .status__content { + color: $ui-base-color; + + a { + color: $ui-highlight-color; + } + + a.status__content__spoiler-link { + color: $primary-text-color; + background: $ui-primary-color; + + &:hover { + background: lighten($ui-primary-color, 8%); + } + } + } + } + + .detailed-status.light { + padding: 14px; + background: $simple-background-color; + cursor: default; + + .detailed-status__display-name { + display: block; + overflow: hidden; + margin-bottom: 15px; + + & > div { + float: left; + margin-right: 10px; + } + + .display-name { + display: block; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + strong { + font-weight: 500; + color: $ui-base-color; + } + + span { + font-size: 14px; + color: $ui-primary-color; + } + } + } + + .avatar { + width: 48px; + height: 48px; + + img { + display: block; + border-radius: 4px; + } + } + + .status__content { + color: $ui-base-color; + + a { + color: $ui-highlight-color; + } + + a.status__content__spoiler-link { + color: $primary-text-color; + background: $ui-primary-color; + + &:hover { + background: lighten($ui-primary-color, 8%); + } + } + } + + .detailed-status__meta { + margin-top: 15px; + color: $ui-primary-color; + font-size: 14px; + line-height: 18px; + + a { + color: inherit; + } + + span > span { + font-weight: 500; + font-size: 12px; + margin-left: 6px; + display: inline-block; + } + } + + .status-card { + border-color: lighten($ui-secondary-color, 4%); + color: darken($ui-primary-color, 4%); + + &:hover { + background: lighten($ui-secondary-color, 4%); + } + } + + .status-card__title, + .status-card__description { + color: $ui-base-color; + } + + .status-card__image { + background: $ui-secondary-color; + } + } + + .media-spoiler { + background: $ui-primary-color; + color: $white; + transition: all 100ms linear; + + &:hover, + &:active, + &:focus { + background: darken($ui-primary-color, 5%); + color: unset; + } + } + + .pre-header { + padding: 14px 0; + padding-left: (48px + 14px * 2); + padding-bottom: 0; + margin-bottom: -4px; + color: $ui-primary-color; + font-size: 14px; + position: relative; + + .pre-header__icon { + position: absolute; + left: (48px + 14px * 2 - 30px); + } + + .status__display-name.muted strong { + color: $ui-primary-color; + } + } + + .open-in-web-link { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.embed { + .activity-stream { + box-shadow: none; + + .entry { + + .detailed-status.light { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + + .detailed-status__display-name { + flex: 1; + margin: 0 5px 15px 0; + } + + .button.button-secondary.logo-button { + flex: 0 auto; + font-size: 14px; + + svg { + width: 20px; + height: auto; + vertical-align: middle; + margin-right: 5px; + + path:first-child { + fill: $ui-primary-color; + } + + path:last-child { + fill: $simple-background-color; + } + } + + &:active, + &:focus, + &:hover { + svg path:first-child { + fill: lighten($ui-primary-color, 4%); + } + } + } + + .status__content, + .detailed-status__meta { + flex: 100%; + } + } + } + } +} diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss new file mode 100644 index 0000000000..ad46f5f9f5 --- /dev/null +++ b/app/javascript/styles/mastodon/tables.scss @@ -0,0 +1,76 @@ +.table { + width: 100%; + max-width: 100%; + border-spacing: 0; + border-collapse: collapse; + + th, + td { + padding: 8px; + line-height: 18px; + vertical-align: top; + border-top: 1px solid $ui-base-color; + text-align: left; + } + + & > thead > tr > th { + vertical-align: bottom; + border-bottom: 2px solid $ui-base-color; + border-top: 0; + font-weight: 500; + } + + & > tbody > tr > th { + font-weight: 500; + } + + & > tbody > tr:nth-child(odd) > td, + & > tbody > tr:nth-child(odd) > th { + background: $ui-base-color; + } + + a { + color: $ui-highlight-color; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + + strong { + font-weight: 500; + } + + &.inline-table > tbody > tr:nth-child(odd) > td, + &.inline-table > tbody > tr:nth-child(odd) > th { + background: transparent; + } +} + +.table-wrapper { + overflow: auto; + margin-bottom: 20px; +} + +samp { + font-family: 'mastodon-font-monospace', monospace; +} + +a.table-action-link { + text-decoration: none; + display: inline-block; + margin-right: 5px; + padding: 0 10px; + color: rgba($primary-text-color, 0.7); + font-weight: 500; + + &:hover { + color: $primary-text-color; + } + + i.fa { + font-weight: 400; + margin-right: 5px; + } +} diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss new file mode 100644 index 0000000000..52c8cd1cf5 --- /dev/null +++ b/app/javascript/styles/mastodon/variables.scss @@ -0,0 +1,29 @@ +// Commonly used web colors +$black: #000000; // Black +$white: #ffffff; // White +$success-green: #79bd9a; // Padua +$error-red: #df405a; // Cerise +$warning-red: #ff5050; // Sunset Orange +$gold-star: #ca8f04; // Dark Goldenrod + +// Values from the classic Mastodon UI +$classic-base-color: #282c37; // Midnight Express +$classic-primary-color: #9baec8; // Echo Blue +$classic-secondary-color: #d9e1e8; // Pattens Blue +$classic-highlight-color: #2b90d9; // Summer Sky + +// Variables for defaults in UI +$base-shadow-color: $black !default; +$base-overlay-background: $black !default; +$base-border-color: $white !default; +$simple-background-color: $white !default; +$primary-text-color: $white !default; +$valid-value-color: $success-green !default; +$error-value-color: $error-red !default; + +// Tell UI to use selected colors +$ui-base-color: $classic-base-color !default; // Darkest +$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest +$ui-primary-color: $classic-primary-color !default; // Lighter +$ui-secondary-color: $classic-secondary-color !default; // Lightest +$ui-highlight-color: $classic-highlight-color !default; // Vibrant