diff options
author | Starfall <root@starfall.blue> | 2019-12-09 19:07:33 -0600 |
---|---|---|
committer | Starfall <root@starfall.blue> | 2019-12-09 19:09:31 -0600 |
commit | 6b34fcfef7566105e8d80ab5fee0a539c06cddbf (patch) | |
tree | 8fad2d47bf8be255d3c671c40cbfd04c2f55ed03 /app/javascript/flavours | |
parent | 9fbb4af7611aa7836e65ef9f544d341423c15685 (diff) | |
parent | 246addd5b33a172600342af3fb6fb5e4c80ad95e (diff) |
Merge branch 'glitch'`
Diffstat (limited to 'app/javascript/flavours')
247 files changed, 7098 insertions, 2315 deletions
diff --git a/app/javascript/flavours/glitch/actions/alerts.js b/app/javascript/flavours/glitch/actions/alerts.js index ef2500e7b..cd36d8007 100644 --- a/app/javascript/flavours/glitch/actions/alerts.js +++ b/app/javascript/flavours/glitch/actions/alerts.js @@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl'; const messages = defineMessages({ unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, + rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' }, + rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' }, }); export const ALERT_SHOW = 'ALERT_SHOW'; @@ -23,23 +25,29 @@ export function clearAlert() { }; }; -export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) { +export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) { return { type: ALERT_SHOW, title, message, + message_values, }; }; export function showAlertForError(error) { if (error.response) { - const { data, status, statusText } = error.response; + const { data, status, statusText, headers } = error.response; if (status === 404 || status === 410) { // Skip these errors as they are reflected in the UI return { type: ALERT_NOOP }; } + if (status === 429 && headers['x-ratelimit-reset']) { + const reset_date = new Date(headers['x-ratelimit-reset']); + return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date }); + } + let message = statusText; let title = `${status}`; diff --git a/app/javascript/flavours/glitch/actions/blocks.js b/app/javascript/flavours/glitch/actions/blocks.js index 498ce519f..adae9d83c 100644 --- a/app/javascript/flavours/glitch/actions/blocks.js +++ b/app/javascript/flavours/glitch/actions/blocks.js @@ -1,6 +1,7 @@ import api, { getLinks } from 'flavours/glitch/util/api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts } from './importer'; +import { openModal } from './modal'; export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; @@ -10,6 +11,8 @@ 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 const BLOCKS_INIT_MODAL = 'BLOCKS_INIT_MODAL'; + export function fetchBlocks() { return (dispatch, getState) => { dispatch(fetchBlocksRequest()); @@ -83,3 +86,14 @@ export function expandBlocksFail(error) { error, }; }; + +export function initBlockModal(account) { + return dispatch => { + dispatch({ + type: BLOCKS_INIT_MODAL, + account, + }); + + dispatch(openModal('BLOCK')); + }; +} diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 69cc6827f..f80642bd8 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -12,7 +12,7 @@ import { showAlertForError } from './alerts'; import { showAlert } from './alerts'; import { defineMessages } from 'react-intl'; -let cancelFetchComposeSuggestionsAccounts; +let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags; export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; export const COMPOSE_CYCLE_ELEFRIEND = 'COMPOSE_CYCLE_ELEFRIEND'; @@ -138,7 +138,8 @@ export function submitCompose(routerHistory) { return function (dispatch, getState) { let status = getState().getIn(['compose', 'text'], ''); let media = getState().getIn(['compose', 'media_attachments']); - let spoilerText = getState().getIn(['compose', 'spoiler_text'], ''); + const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']); + let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : ''; if ((!status || !status.length) && media.size === 0) { return; @@ -231,10 +232,11 @@ export function uploadCompose(files) { return function (dispatch, getState) { const uploadLimit = 4; const media = getState().getIn(['compose', 'media_attachments']); + const pending = getState().getIn(['compose', 'pending_media_attachments']); const progress = new Array(files.length).fill(0); let total = Array.from(files).reduce((a, v) => a + v.size, 0); - if (files.length + media.size > uploadLimit) { + if (files.length + media.size + pending > uploadLimit) { dispatch(showAlert(undefined, messages.uploadErrorLimit)); return; } @@ -260,7 +262,7 @@ export function uploadCompose(files) { progress[i] = loaded; dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); }, - }).then(({ data }) => dispatch(uploadComposeSuccess(data))); + }).then(({ data }) => dispatch(uploadComposeSuccess(data, f))); }).catch(error => dispatch(uploadComposeFail(error))); }; }; @@ -315,10 +317,11 @@ export function uploadComposeProgress(loaded, total) { }; }; -export function uploadComposeSuccess(media) { +export function uploadComposeSuccess(media, file) { return { type: COMPOSE_UPLOAD_SUCCESS, media: media, + file: file, skipLoading: true, }; }; @@ -351,10 +354,12 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => if (cancelFetchComposeSuggestionsAccounts) { cancelFetchComposeSuggestionsAccounts(); } + api(getState).get('/api/v1/accounts/search', { cancelToken: new CancelToken(cancel => { cancelFetchComposeSuggestionsAccounts = cancel; }), + params: { q: token.slice(1), resolve: false, @@ -375,9 +380,32 @@ const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { dispatch(readyComposeSuggestionsEmojis(token, results)); }; -const fetchComposeSuggestionsTags = (dispatch, getState, token) => { +const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => { + if (cancelFetchComposeSuggestionsTags) { + cancelFetchComposeSuggestionsTags(); + } + dispatch(updateSuggestionTags(token)); -}; + + api(getState).get('/api/v2/search', { + cancelToken: new CancelToken(cancel => { + cancelFetchComposeSuggestionsTags = cancel; + }), + + params: { + type: 'hashtags', + q: token.slice(1), + resolve: false, + limit: 4, + }, + }).then(({ data }) => { + dispatch(readyComposeSuggestionsTags(token, data.hashtags)); + }).catch(error => { + if (!isCancel(error)) { + dispatch(showAlertForError(error)); + } + }); +}, 200, { leading: true, trailing: true }); export function fetchComposeSuggestions(token) { return (dispatch, getState) => { @@ -411,16 +439,22 @@ export function readyComposeSuggestionsAccounts(token, accounts) { }; }; +export const readyComposeSuggestionsTags = (token, tags) => ({ + type: COMPOSE_SUGGESTIONS_READY, + token, + tags, +}); + export function selectComposeSuggestion(position, token, suggestion, path) { return (dispatch, getState) => { let completion; - if (typeof suggestion === 'object' && suggestion.id) { + if (suggestion.type === 'emoji') { dispatch(useEmoji(suggestion)); completion = suggestion.native || suggestion.colons; - } else if (suggestion[0] === '#') { - completion = suggestion; - } else { - completion = '@' + getState().getIn(['accounts', suggestion, 'acct']); + } else if (suggestion.type === 'hashtag') { + completion = `#${suggestion.name}`; + } else if (suggestion.type === 'account') { + completion = '@' + getState().getIn(['accounts', suggestion.id, 'acct']); } dispatch({ diff --git a/app/javascript/flavours/glitch/actions/conversations.js b/app/javascript/flavours/glitch/actions/conversations.js index 856f8f10f..e5c85c65d 100644 --- a/app/javascript/flavours/glitch/actions/conversations.js +++ b/app/javascript/flavours/glitch/actions/conversations.js @@ -15,6 +15,10 @@ export const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE'; export const CONVERSATIONS_READ = 'CONVERSATIONS_READ'; +export const CONVERSATIONS_DELETE_REQUEST = 'CONVERSATIONS_DELETE_REQUEST'; +export const CONVERSATIONS_DELETE_SUCCESS = 'CONVERSATIONS_DELETE_SUCCESS'; +export const CONVERSATIONS_DELETE_FAIL = 'CONVERSATIONS_DELETE_FAIL'; + export const mountConversations = () => ({ type: CONVERSATIONS_MOUNT, }); @@ -82,3 +86,27 @@ export const updateConversations = conversation => dispatch => { conversation, }); }; + +export const deleteConversation = conversationId => (dispatch, getState) => { + dispatch(deleteConversationRequest(conversationId)); + + api(getState).delete(`/api/v1/conversations/${conversationId}`) + .then(() => dispatch(deleteConversationSuccess(conversationId))) + .catch(error => dispatch(deleteConversationFail(conversationId, error))); +}; + +export const deleteConversationRequest = id => ({ + type: CONVERSATIONS_DELETE_REQUEST, + id, +}); + +export const deleteConversationSuccess = id => ({ + type: CONVERSATIONS_DELETE_SUCCESS, + id, +}); + +export const deleteConversationFail = (id, error) => ({ + type: CONVERSATIONS_DELETE_FAIL, + id, + error, +}); diff --git a/app/javascript/flavours/glitch/actions/directory.js b/app/javascript/flavours/glitch/actions/directory.js new file mode 100644 index 000000000..9fbfb7f5b --- /dev/null +++ b/app/javascript/flavours/glitch/actions/directory.js @@ -0,0 +1,61 @@ +import api from 'flavours/glitch/util/api'; +import { importFetchedAccounts } from './importer'; +import { fetchRelationships } from './accounts'; + +export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; +export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; +export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; + +export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; +export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; +export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; + +export const fetchDirectory = params => (dispatch, getState) => { + dispatch(fetchDirectoryRequest()); + + api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(fetchDirectoryFail(error))); +}; + +export const fetchDirectoryRequest = () => ({ + type: DIRECTORY_FETCH_REQUEST, +}); + +export const fetchDirectorySuccess = accounts => ({ + type: DIRECTORY_FETCH_SUCCESS, + accounts, +}); + +export const fetchDirectoryFail = error => ({ + type: DIRECTORY_FETCH_FAIL, + error, +}); + +export const expandDirectory = params => (dispatch, getState) => { + dispatch(expandDirectoryRequest()); + + const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size; + + api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(expandDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(expandDirectoryFail(error))); +}; + +export const expandDirectoryRequest = () => ({ + type: DIRECTORY_EXPAND_REQUEST, +}); + +export const expandDirectorySuccess = accounts => ({ + type: DIRECTORY_EXPAND_SUCCESS, + accounts, +}); + +export const expandDirectoryFail = error => ({ + type: DIRECTORY_EXPAND_FAIL, + error, +}); diff --git a/app/javascript/flavours/glitch/actions/domain_blocks.js b/app/javascript/flavours/glitch/actions/domain_blocks.js index 7397f561b..6d3f471fa 100644 --- a/app/javascript/flavours/glitch/actions/domain_blocks.js +++ b/app/javascript/flavours/glitch/actions/domain_blocks.js @@ -23,6 +23,7 @@ export function blockDomain(domain) { api(getState).post('/api/v1/domain_blocks', { domain }).then(() => { const at_domain = '@' + domain; const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); + dispatch(blockDomainSuccess(domain, accounts)); }).catch(err => { dispatch(blockDomainFail(domain, err)); diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index a8c3fe16a..2bc603930 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -10,6 +10,12 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { return obj; }, {}); +export function searchTextFromRawStatus (status) { + const spoilerText = status.spoiler_text || ''; + const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; +} + export function normalizeAccount(account) { account = { ...account }; @@ -22,7 +28,7 @@ export function normalizeAccount(account) { if (account.fields) { account.fields = account.fields.map(pair => ({ ...pair, - name_emojified: emojify(escapeTextContentForBrowser(pair.name)), + name_emojified: emojify(escapeTextContentForBrowser(pair.name), emojiMap), value_emojified: emojify(pair.value, emojiMap), value_plain: unescapeHTML(pair.value), })); @@ -55,7 +61,7 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); } else { const spoilerText = normalStatus.spoiler_text || ''; - const searchContent = [spoilerText, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); const emojiMap = makeEmojiMap(normalStatus); normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; @@ -71,8 +77,9 @@ export function normalizePoll(poll) { const emojiMap = makeEmojiMap(normalPoll); - normalPoll.options = poll.options.map(option => ({ + normalPoll.options = poll.options.map((option, index) => ({ ...option, + voted: poll.own_votes && poll.own_votes.includes(index), title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap), })); diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js new file mode 100644 index 000000000..c3a5fe86f --- /dev/null +++ b/app/javascript/flavours/glitch/actions/markers.js @@ -0,0 +1,30 @@ +export const submitMarkers = () => (dispatch, getState) => { + const accessToken = getState().getIn(['meta', 'access_token'], ''); + const params = {}; + + const lastHomeId = getState().getIn(['timelines', 'home', 'items', 0]); + const lastNotificationId = getState().getIn(['notifications', 'items', 0, 'id']); + + if (lastHomeId) { + params.home = { + last_read_id: lastHomeId, + }; + } + + if (lastNotificationId) { + params.notifications = { + last_read_id: lastNotificationId, + }; + } + + if (Object.keys(params).length === 0) { + return; + } + + const client = new XMLHttpRequest(); + + client.open('POST', '/api/v1/markers', false); + client.setRequestHeader('Content-Type', 'application/json'); + client.setRequestHeader('Authorization', `Bearer ${accessToken}`); + client.send(JSON.stringify(params)); +}; diff --git a/app/javascript/flavours/glitch/actions/modal.js b/app/javascript/flavours/glitch/actions/modal.js index 80e15c28e..3d0299db5 100644 --- a/app/javascript/flavours/glitch/actions/modal.js +++ b/app/javascript/flavours/glitch/actions/modal.js @@ -9,8 +9,9 @@ export function openModal(type, props) { }; }; -export function closeModal() { +export function closeModal(type) { return { type: MODAL_CLOSE, + modalType: type, }; }; diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 57fecf63d..940f3c3d4 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -11,7 +11,10 @@ import { saveSettings } from './settings'; import { defineMessages } from 'react-intl'; import { List as ImmutableList } from 'immutable'; import { unescapeHTML } from 'flavours/glitch/util/html'; -import { getFilters, regexFromFilters } from 'flavours/glitch/selectors'; +import { getFiltersRegex } from 'flavours/glitch/selectors'; +import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state'; +import compareId from 'flavours/glitch/util/compare_id'; +import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; @@ -32,8 +35,9 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; -export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; -export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; +export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; +export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; +export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; @@ -52,18 +56,27 @@ const fetchRelatedRelationships = (dispatch, notifications) => { } }; +export const loadPending = () => ({ + type: NOTIFICATIONS_LOAD_PENDING, +}); + export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true); const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); - const filters = getFilters(getState(), { contextType: 'notifications' }); + const filters = getFiltersRegex(getState(), { contextType: 'notifications' }); let filtered = false; if (notification.type === 'mention') { - const regex = regexFromFilters(filters); - const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content); + const dropRegex = filters[0]; + const regex = filters[1]; + const searchIndex = searchTextFromRawStatus(notification.status); + + if (dropRegex && dropRegex.test(searchIndex)) { + return; + } filtered = regex && regex.test(searchIndex); } @@ -78,6 +91,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { dispatch({ type: NOTIFICATIONS_UPDATE, notification, + usePendingItems: preferPendingItems, meta: (playSound && !filtered) ? { sound: 'boop' } : undefined, }); @@ -107,7 +121,7 @@ const excludeTypesFromSettings = state => state.getIn(['settings', 'notification const excludeTypesFromFilter = filter => { - const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']); + const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']); return allTypes.filterNot(item => item === filter).toJS(); }; @@ -131,10 +145,19 @@ export function expandNotifications({ maxId } = {}, done = noOp) { : excludeTypesFromFilter(activeFilter), }; - if (!maxId && notifications.get('items').size > 0) { - params.since_id = notifications.getIn(['items', 0, 'id']); + if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) { + const a = notifications.getIn(['pendingItems', 0, 'id']); + const b = notifications.getIn(['items', 0, 'id']); + + if (a && b && compareId(a, b) > 0) { + params.since_id = a; + } else { + params.since_id = b || a; + } } + const isLoadingRecent = !!params.since_id; + dispatch(expandNotificationsRequest(isLoadingMore)); api(getState).get('/api/v1/notifications', { params }).then(response => { @@ -143,7 +166,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) { dispatch(importFetchedAccounts(response.data.map(item => item.account))); dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); - dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore)); + dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems)); fetchRelatedRelationships(dispatch, response.data); done(); }).catch(error => { @@ -160,13 +183,13 @@ export function expandNotificationsRequest(isLoadingMore) { }; }; -export function expandNotificationsSuccess(notifications, next, isLoadingMore) { +export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) { return { type: NOTIFICATIONS_EXPAND_SUCCESS, notifications, - accounts: notifications.map(item => item.account), - statuses: notifications.map(item => item.status).filter(status => !!status), next, + isLoadingRecent: isLoadingRecent, + usePendingItems, skipLoading: !isLoadingMore, }; }; diff --git a/app/javascript/flavours/glitch/actions/polls.js b/app/javascript/flavours/glitch/actions/polls.js index 8e8b82df5..ca94a095f 100644 --- a/app/javascript/flavours/glitch/actions/polls.js +++ b/app/javascript/flavours/glitch/actions/polls.js @@ -1,4 +1,4 @@ -import api from '../api'; +import api from 'flavours/glitch/util/api'; import { importFetchedPoll } from './importer'; export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js index b2d24e10b..a025f352a 100644 --- a/app/javascript/flavours/glitch/actions/search.js +++ b/app/javascript/flavours/glitch/actions/search.js @@ -10,6 +10,10 @@ 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 const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST'; +export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; +export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; + export function changeSearch(value) { return { type: SEARCH_CHANGE, @@ -48,7 +52,7 @@ export function submitSearch() { dispatch(importFetchedStatuses(response.data.statuses)); } - dispatch(fetchSearchSuccess(response.data)); + dispatch(fetchSearchSuccess(response.data, value)); dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); }).catch(error => { dispatch(fetchSearchFail(error)); @@ -62,12 +66,11 @@ export function fetchSearchRequest() { }; }; -export function fetchSearchSuccess(results) { +export function fetchSearchSuccess(results, searchTerm) { return { type: SEARCH_FETCH_SUCCESS, results, - accounts: results.accounts, - statuses: results.statuses, + searchTerm, }; }; @@ -78,8 +81,50 @@ export function fetchSearchFail(error) { }; }; -export function showSearch() { - return { - type: SEARCH_SHOW, - }; +export const expandSearch = type => (dispatch, getState) => { + const value = getState().getIn(['search', 'value']); + const offset = getState().getIn(['search', 'results', type]).size; + + dispatch(expandSearchRequest()); + + api(getState).get('/api/v2/search', { + params: { + q: value, + type, + offset, + }, + }).then(({ data }) => { + if (data.accounts) { + dispatch(importFetchedAccounts(data.accounts)); + } + + if (data.statuses) { + dispatch(importFetchedStatuses(data.statuses)); + } + + dispatch(expandSearchSuccess(data, value, type)); + dispatch(fetchRelationships(data.accounts.map(item => item.id))); + }).catch(error => { + dispatch(expandSearchFail(error)); + }); }; + +export const expandSearchRequest = () => ({ + type: SEARCH_EXPAND_REQUEST, +}); + +export const expandSearchSuccess = (results, searchTerm, searchType) => ({ + type: SEARCH_EXPAND_SUCCESS, + results, + searchTerm, + searchType, +}); + +export const expandSearchFail = error => ({ + type: SEARCH_EXPAND_FAIL, + error, +}); + +export const showSearch = () => ({ + type: SEARCH_SHOW, +}); diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index cca571583..097878c3b 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -1,6 +1,10 @@ import { importFetchedStatus, importFetchedStatuses } from './importer'; import api, { getLinks } from 'flavours/glitch/util/api'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import compareId from 'flavours/glitch/util/compare_id'; +import { me, usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state'; +import { getFiltersRegex } from 'flavours/glitch/selectors'; +import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_DELETE = 'TIMELINE_DELETE'; @@ -10,23 +14,40 @@ 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_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; +export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING'; +export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; -export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; -export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +export const loadPending = timeline => ({ + type: TIMELINE_LOAD_PENDING, + timeline, +}); export function updateTimeline(timeline, status, accept) { - return dispatch => { + return (dispatch, getState) => { if (typeof accept === 'function' && !accept(status)) { return; } + const filters = getFiltersRegex(getState(), { contextType: timeline }); + const dropRegex = filters[0]; + const regex = filters[1]; + const text = searchTextFromRawStatus(status); + let filtered = false; + + if (status.account.id !== me) { + filtered = (dropRegex && dropRegex.test(text)) || (regex && regex.test(text)); + } + dispatch(importFetchedStatus(status)); dispatch({ type: TIMELINE_UPDATE, timeline, status, + usePendingItems: preferPendingItems, + filtered }); }; }; @@ -71,8 +92,15 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { return; } - if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) { - params.since_id = timeline.getIn(['items', 0]); + if (!params.max_id && !params.pinned && (timeline.get('items', ImmutableList()).size + timeline.get('pendingItems', ImmutableList()).size) > 0) { + const a = timeline.getIn(['pendingItems', 0]); + const b = timeline.getIn(['items', 0]); + + if (a && b && compareId(a, b) > 0) { + params.since_id = a; + } else { + params.since_id = b || a; + } } const isLoadingRecent = !!params.since_id; @@ -81,8 +109,9 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); - dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore)); + dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); done(); }).catch(error => { dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); @@ -117,7 +146,7 @@ export function expandTimelineRequest(timeline, isLoadingMore) { }; }; -export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore) { +export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore, usePendingItems) { return { type: TIMELINE_EXPAND_SUCCESS, timeline, @@ -125,6 +154,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadi next, partial, isLoadingRecent, + usePendingItems, skipLoading: !isLoadingMore, }; }; @@ -153,9 +183,8 @@ export function connectTimeline(timeline) { }; }; -export function disconnectTimeline(timeline) { - return { - type: TIMELINE_DISCONNECT, - timeline, - }; -}; +export const disconnectTimeline = timeline => ({ + type: TIMELINE_DISCONNECT, + timeline, + usePendingItems: preferPendingItems, +}); diff --git a/app/javascript/flavours/glitch/actions/trends.js b/app/javascript/flavours/glitch/actions/trends.js new file mode 100644 index 000000000..1b0ce2b5b --- /dev/null +++ b/app/javascript/flavours/glitch/actions/trends.js @@ -0,0 +1,32 @@ +import api from 'flavours/glitch/util/api'; + +export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST'; +export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS'; +export const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL'; + +export const fetchTrends = () => (dispatch, getState) => { + dispatch(fetchTrendsRequest()); + + api(getState) + .get('/api/v1/trends') + .then(({ data }) => dispatch(fetchTrendsSuccess(data))) + .catch(err => dispatch(fetchTrendsFail(err))); +}; + +export const fetchTrendsRequest = () => ({ + type: TRENDS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchTrendsSuccess = trends => ({ + type: TRENDS_FETCH_SUCCESS, + trends, + skipLoading: true, +}); + +export const fetchTrendsFail = error => ({ + type: TRENDS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js index 3fc18cb72..f3e58dfe3 100644 --- a/app/javascript/flavours/glitch/components/account.js +++ b/app/javascript/flavours/glitch/components/account.js @@ -19,8 +19,8 @@ const messages = defineMessages({ unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'You are currently muting notifications from @{name}. Click to unmute notifications' }, }); -@injectIntl -export default class Account extends ImmutablePureComponent { +export default @injectIntl +class Account extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, diff --git a/app/javascript/flavours/glitch/components/attachment_list.js b/app/javascript/flavours/glitch/components/attachment_list.js index 8e5bb0e0b..68d8d29c7 100644 --- a/app/javascript/flavours/glitch/components/attachment_list.js +++ b/app/javascript/flavours/glitch/components/attachment_list.js @@ -2,6 +2,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import Icon from 'flavours/glitch/components/icon'; const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; @@ -24,7 +25,7 @@ export default class AttachmentList extends ImmutablePureComponent { return ( <li key={attachment.get('id')}> - <a href={displayUrl} target='_blank' rel='noopener'><i className='fa fa-link' /> {filename(displayUrl)}</a> + <a href={displayUrl} target='_blank' rel='noopener noreferrer'><Icon id='link' /> {filename(displayUrl)}</a> </li> ); })} @@ -36,7 +37,7 @@ export default class AttachmentList extends ImmutablePureComponent { return ( <div className='attachment-list'> <div className='attachment-list__icon'> - <i className='fa fa-link' /> + <Icon id='link' /> </div> <ul className='attachment-list__list'> @@ -45,7 +46,7 @@ export default class AttachmentList extends ImmutablePureComponent { return ( <li key={attachment.get('id')}> - <a href={displayUrl} target='_blank' rel='noopener'>{filename(displayUrl)}</a> + <a href={displayUrl} target='_blank' rel='noopener noreferrer'>{filename(displayUrl)}</a> </li> ); })} diff --git a/app/javascript/flavours/glitch/components/autosuggest_hashtag.js b/app/javascript/flavours/glitch/components/autosuggest_hashtag.js new file mode 100644 index 000000000..648987dfd --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_hashtag.js @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { shortNumberFormat } from 'flavours/glitch/util/numbers'; +import { FormattedMessage } from 'react-intl'; + +export default class AutosuggestHashtag extends React.PureComponent { + + static propTypes = { + tag: PropTypes.shape({ + name: PropTypes.string.isRequired, + url: PropTypes.string, + history: PropTypes.array, + }).isRequired, + }; + + render () { + const { tag } = this.props; + const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0)); + + return ( + <div className='autosuggest-hashtag'> + <div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div> + {tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/autosuggest_input.js b/app/javascript/flavours/glitch/components/autosuggest_input.js index 5fc952d8e..1ef7ee216 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_input.js +++ b/app/javascript/flavours/glitch/components/autosuggest_input.js @@ -1,6 +1,7 @@ import React from 'react'; import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; import AutosuggestEmoji from './autosuggest_emoji'; +import AutosuggestHashtag from './autosuggest_hashtag'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { isRtl } from 'flavours/glitch/util/rtl'; @@ -167,15 +168,15 @@ export default class AutosuggestInput extends ImmutablePureComponent { const { selectedSuggestion } = this.state; let inner, key; - if (typeof suggestion === 'object') { + if (suggestion.type === 'emoji') { inner = <AutosuggestEmoji emoji={suggestion} />; key = suggestion.id; - } else if (suggestion[0] === '#') { - inner = suggestion; - key = suggestion; - } else { - inner = <AutosuggestAccountContainer id={suggestion} />; - key = suggestion; + } else if (suggestion.type ==='hashtag') { + inner = <AutosuggestHashtag tag={suggestion} />; + key = suggestion.name; + } else if (suggestion.type === 'account') { + inner = <AutosuggestAccountContainer id={suggestion.id} />; + key = suggestion.id; } return ( diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js index bbe0ffcbe..ec2fbbe4b 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_textarea.js +++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.js @@ -1,6 +1,7 @@ import React from 'react'; import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; import AutosuggestEmoji from './autosuggest_emoji'; +import AutosuggestHashtag from './autosuggest_hashtag'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { isRtl } from 'flavours/glitch/util/rtl'; @@ -173,15 +174,15 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { const { selectedSuggestion } = this.state; let inner, key; - if (typeof suggestion === 'object') { + if (suggestion.type === 'emoji') { inner = <AutosuggestEmoji emoji={suggestion} />; key = suggestion.id; - } else if (suggestion[0] === '#') { - inner = suggestion; - key = suggestion; - } else { - inner = <AutosuggestAccountContainer id={suggestion} />; - key = suggestion; + } else if (suggestion.type === 'hashtag') { + inner = <AutosuggestHashtag tag={suggestion} />; + key = suggestion.name; + } else if (suggestion.type === 'account') { + inner = <AutosuggestAccountContainer id={suggestion.id} />; + key = suggestion.id; } return ( diff --git a/app/javascript/flavours/glitch/components/avatar_composite.js b/app/javascript/flavours/glitch/components/avatar_composite.js index c52df043a..125b51c44 100644 --- a/app/javascript/flavours/glitch/components/avatar_composite.js +++ b/app/javascript/flavours/glitch/components/avatar_composite.js @@ -35,35 +35,35 @@ export default class AvatarComposite extends React.PureComponent { if (size === 2) { if (index === 0) { - right = '2px'; + right = '1px'; } else { - left = '2px'; + left = '1px'; } } else if (size === 3) { if (index === 0) { - right = '2px'; + right = '1px'; } else if (index > 0) { - left = '2px'; + left = '1px'; } if (index === 1) { - bottom = '2px'; + bottom = '1px'; } else if (index > 1) { - top = '2px'; + top = '1px'; } } else if (size === 4) { if (index === 0 || index === 2) { - right = '2px'; + right = '1px'; } if (index === 1 || index === 3) { - left = '2px'; + left = '1px'; } if (index < 2) { - bottom = '2px'; + bottom = '1px'; } else { - top = '2px'; + top = '1px'; } } @@ -96,7 +96,13 @@ export default class AvatarComposite extends React.PureComponent { return ( <div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}> - {accounts.take(4).map((account, i) => this.renderItem(account, accounts.size, i))} + {accounts.take(4).map((account, i) => this.renderItem(account, Math.min(accounts.size, 4), i))} + + {accounts.size > 4 && ( + <span className='account__avatar-composite__label'> + +{accounts.size - 4} + </span> + )} </div> ); } diff --git a/app/javascript/flavours/glitch/components/button.js b/app/javascript/flavours/glitch/components/button.js index 16868010c..cd6528f58 100644 --- a/app/javascript/flavours/glitch/components/button.js +++ b/app/javascript/flavours/glitch/components/button.js @@ -12,9 +12,9 @@ export default class Button extends React.PureComponent { secondary: PropTypes.bool, size: PropTypes.number, className: PropTypes.string, + title: PropTypes.string, style: PropTypes.object, children: PropTypes.node, - title: PropTypes.string, }; static defaultProps = { diff --git a/app/javascript/flavours/glitch/components/column.js b/app/javascript/flavours/glitch/components/column.js index dc87818a5..5819d5362 100644 --- a/app/javascript/flavours/glitch/components/column.js +++ b/app/javascript/flavours/glitch/components/column.js @@ -10,10 +10,11 @@ export default class Column extends React.PureComponent { extraClasses: PropTypes.string, name: PropTypes.string, label: PropTypes.string, + bindToDocument: PropTypes.bool, }; scrollTop () { - const scrollable = this.node.querySelector('.scrollable'); + const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable'); if (!scrollable) { return; @@ -35,11 +36,19 @@ export default class Column extends React.PureComponent { } componentDidMount () { - this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + if (this.props.bindToDocument) { + document.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } else { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } } componentWillUnmount () { - this.node.removeEventListener('wheel', this.handleWheel); + if (this.props.bindToDocument) { + document.removeEventListener('wheel', this.handleWheel); + } else { + this.node.removeEventListener('wheel', this.handleWheel); + } } render () { diff --git a/app/javascript/flavours/glitch/components/column_back_button.js b/app/javascript/flavours/glitch/components/column_back_button.js index 82556d22e..05688f867 100644 --- a/app/javascript/flavours/glitch/components/column_back_button.js +++ b/app/javascript/flavours/glitch/components/column_back_button.js @@ -1,6 +1,8 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; +import { createPortal } from 'react-dom'; export default class ColumnBackButton extends React.PureComponent { @@ -8,6 +10,10 @@ export default class ColumnBackButton extends React.PureComponent { router: PropTypes.object, }; + static propTypes = { + multiColumn: PropTypes.bool, + }; + handleClick = (event) => { // if history is exhausted, or we would leave mastodon, just go to root. if (window.history.state) { @@ -23,12 +29,32 @@ export default class ColumnBackButton extends React.PureComponent { } render () { - return ( + const { multiColumn } = this.props; + + const component = ( <button onClick={this.handleClick} className='column-back-button'> - <i className='fa fa-fw fa-chevron-left column-back-button__icon' /> + <Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> </button> ); + + if (multiColumn) { + return component; + } else { + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } + } } } diff --git a/app/javascript/flavours/glitch/components/column_back_button_slim.js b/app/javascript/flavours/glitch/components/column_back_button_slim.js index 38afd3df3..faa0c23a8 100644 --- a/app/javascript/flavours/glitch/components/column_back_button_slim.js +++ b/app/javascript/flavours/glitch/components/column_back_button_slim.js @@ -1,6 +1,7 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; export default class ColumnBackButtonSlim extends React.PureComponent { @@ -26,7 +27,7 @@ export default class ColumnBackButtonSlim extends React.PureComponent { return ( <div className='column-back-button--slim'> <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'> - <i className='fa fa-fw fa-chevron-left column-back-button__icon' /> + <Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> </div> </div> diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js index a0ff09986..dd1162429 100644 --- a/app/javascript/flavours/glitch/components/column_header.js +++ b/app/javascript/flavours/glitch/components/column_header.js @@ -1,8 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { createPortal } from 'react-dom'; import classNames from 'classnames'; import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import Icon from 'flavours/glitch/components/icon'; import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container'; @@ -14,8 +16,8 @@ const messages = defineMessages({ enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' }, }); -@injectIntl -export default class ColumnHeader extends React.PureComponent { +export default @injectIntl +class ColumnHeader extends React.PureComponent { static contextTypes = { router: PropTypes.object, @@ -35,6 +37,7 @@ export default class ColumnHeader extends React.PureComponent { onEnterCleaningMode: PropTypes.func, children: PropTypes.node, pinned: PropTypes.bool, + placeholder: PropTypes.bool, onPin: PropTypes.func, onMove: PropTypes.func, onClick: PropTypes.func, @@ -103,7 +106,7 @@ export default class ColumnHeader extends React.PureComponent { } render () { - const { intl, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, notifCleaning, notifCleaningActive } = this.props; + const { intl, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, notifCleaning, notifCleaningActive, placeholder } = this.props; const { collapsed, animating, animatingNCD } = this.state; let title = this.props.title; @@ -148,22 +151,22 @@ export default class ColumnHeader extends React.PureComponent { } if (multiColumn && pinned) { - pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><i className='fa fa fa-times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>; + pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>; moveButtons = ( <div key='move-buttons' className='column-header__setting-arrows'> - <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><i className='fa fa-chevron-left' /></button> - <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><i className='fa fa-chevron-right' /></button> + <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button> + <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button> </div> ); - } else if (multiColumn) { - pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><i className='fa fa fa-plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; + } else if (multiColumn && this.props.onPin) { + pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; } if (!pinned && (multiColumn || showBackButton)) { backButton = ( <button onClick={this.handleBackClick} className='column-header__back-button'> - <i className='fa fa-fw fa-chevron-left column-back-button__icon' /> + <Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> </button> ); @@ -178,18 +181,18 @@ export default class ColumnHeader extends React.PureComponent { collapsedContent.push(pinButton); } - if (children || multiColumn) { - collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>; + if (children || (multiColumn && this.props.onPin)) { + collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>; } const hasTitle = icon && title; - return ( + const component = ( <div className={wrapperClassName}> <h1 className={buttonClassName}> {hasTitle && ( <button onClick={this.handleTitleClick}> - <i className={`fa fa-fw fa-${icon} column-header__icon`} /> + <Icon id={icon} fixedWidth className='column-header__icon' /> {title} </button> )} @@ -206,7 +209,7 @@ export default class ColumnHeader extends React.PureComponent { onClick={this.onEnterCleaningMode} className={notifCleaningButtonClassName} > - <i className='fa fa-eraser' /> + <Icon id='eraser' /> </button> ) : null} {collapseButton} @@ -228,6 +231,24 @@ export default class ColumnHeader extends React.PureComponent { </div> </div> ); + + if (multiColumn || placeholder) { + return component; + } else { + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } + } } } diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js index 7f6ef5a5d..44662a8b8 100644 --- a/app/javascript/flavours/glitch/components/display_name.js +++ b/app/javascript/flavours/glitch/components/display_name.js @@ -1,73 +1,112 @@ -// Package imports. -import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; + +export default class DisplayName extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map, + className: PropTypes.string, + inline: PropTypes.bool, + localDomain: PropTypes.string, + others: ImmutablePropTypes.list, + handleClick: PropTypes.func, + }; + + _updateEmojis () { + const node = this.node; + + if (!node || autoPlayGif) { + return; + } + + const emojis = node.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + if (emoji.classList.contains('status-emoji')) { + continue; + } + emoji.classList.add('status-emoji'); + + emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false); + emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false); + } + } -// The component. -export default function DisplayName ({ - account, - className, - inline, - localDomain, - others, - onAccountClick, -}) { - const computedClass = classNames('display-name', { inline }, className); + componentDidMount () { + this._updateEmojis(); + } - if (!account) return null; + componentDidUpdate () { + this._updateEmojis(); + } - let displayName, suffix; + handleEmojiMouseEnter = ({ target }) => { + target.src = target.getAttribute('data-original'); + } - let acct = account.get('acct'); + handleEmojiMouseLeave = ({ target }) => { + target.src = target.getAttribute('data-static'); + } - if (acct.indexOf('@') === -1 && localDomain) { - acct = `${acct}@${localDomain}`; + setRef = (c) => { + this.node = c; } - if (others && others.size > 0) { - displayName = others.take(2).map(a => ( - <a - href={a.get('url')} - target='_blank' - onClick={(e) => onAccountClick(a.get('id'), e)} - title={`@${a.get('acct')}`} - > - <bdi key={a.get('id')}> - <strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /> - </bdi> - </a> - )).reduce((prev, cur) => [prev, ', ', cur]); - - if (others.size - 2 > 0) { - displayName.push(` +${others.size - 2}`); + render() { + const { account, className, inline, localDomain, others, onAccountClick } = this.props; + + const computedClass = classNames('display-name', { inline }, className); + + if (!account) return null; + + let displayName, suffix; + + let acct = account.get('acct'); + + if (acct.indexOf('@') === -1 && localDomain) { + acct = `${acct}@${localDomain}`; } - suffix = ( - <a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('id'), e)}> - <span className='display-name__account'>@{acct}</span> - </a> + if (others && others.size > 0) { + displayName = others.take(2).map(a => ( + <a + href={a.get('url')} + target='_blank' + onClick={(e) => onAccountClick(a.get('id'), e)} + title={`@${a.get('acct')}`} + rel='noopener noreferrer' + > + <bdi key={a.get('id')}> + <strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /> + </bdi> + </a> + )).reduce((prev, cur) => [prev, ', ', cur]); + + if (others.size - 2 > 0) { + displayName.push(` +${others.size - 2}`); + } + + suffix = ( + <a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('id'), e)} rel='noopener noreferrer'> + <span className='display-name__account'>@{acct}</span> + </a> + ); + } else { + displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>; + suffix = <span className='display-name__account'>@{acct}</span>; + } + + return ( + <span className={computedClass} ref={this.setRef}> + {displayName} + {inline ? ' ' : null} + {suffix} + </span> ); - } else { - displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>; - suffix = <span className='display-name__account'>@{acct}</span>; } - return ( - <span className={computedClass}> - {displayName} - {inline ? ' ' : null} - {suffix} - </span> - ); } - -// Props. -DisplayName.propTypes = { - account: ImmutablePropTypes.map, - className: PropTypes.string, - inline: PropTypes.bool, - localDomain: PropTypes.string, - others: ImmutablePropTypes.list, - handleClick: PropTypes.func, -}; diff --git a/app/javascript/flavours/glitch/components/domain.js b/app/javascript/flavours/glitch/components/domain.js index 74174f83d..85729ca94 100644 --- a/app/javascript/flavours/glitch/components/domain.js +++ b/app/javascript/flavours/glitch/components/domain.js @@ -8,8 +8,8 @@ const messages = defineMessages({ unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, }); -@injectIntl -export default class Account extends ImmutablePureComponent { +export default @injectIntl +class Account extends ImmutablePureComponent { static propTypes = { domain: PropTypes.string, diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js index 05611c135..ab5b7a572 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/components/dropdown_menu.js @@ -45,7 +45,9 @@ class DropdownMenu extends React.PureComponent { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('keydown', this.handleKeyDown, false); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus(); + if (this.focusedItem && this.props.openedViaKeyboard) { + this.focusedItem.focus(); + } this.setState({ mounted: true }); } @@ -81,6 +83,18 @@ class DropdownMenu extends React.PureComponent { element.focus(); } break; + case 'Tab': + if (e.shiftKey) { + element = items[index-1] || items[items.length-1]; + } else { + element = items[index+1] || items[0]; + } + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + break; case 'Home': element = items[0]; if (element) { @@ -93,11 +107,14 @@ class DropdownMenu extends React.PureComponent { element.focus(); } break; + case 'Escape': + this.props.onClose(); + break; } } - handleItemKeyDown = e => { - if (e.key === 'Enter') { + handleItemKeyPress = e => { + if (e.key === 'Enter' || e.key === ' ') { this.handleClick(e); } } @@ -126,7 +143,7 @@ class DropdownMenu extends React.PureComponent { return ( <li className='dropdown-menu__item' key={`${text}-${i}`}> - <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyDown={this.handleItemKeyDown} data-index={i}> + <a href={href} target='_blank' rel='noopener noreferrer' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}> {text} </a> </li> @@ -193,25 +210,41 @@ export default class Dropdown extends React.PureComponent { } else { const { top } = target.getBoundingClientRect(); const placement = top * 2 < innerHeight ? 'bottom' : 'top'; - this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click'); } } handleClose = () => { + if (this.activeElement) { + this.activeElement.focus(); + this.activeElement = null; + } this.props.onClose(this.state.id); } - handleKeyDown = e => { + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + } + + handleKeyPress = (e) => { switch(e.key) { case ' ': case 'Enter': this.handleClick(e); + e.stopPropagation(); e.preventDefault(); break; - case 'Escape': - this.handleClose(); - break; } } @@ -248,7 +281,7 @@ export default class Dropdown extends React.PureComponent { const open = this.state.id === openDropdownId; return ( - <div onKeyDown={this.handleKeyDown}> + <div> <IconButton icon={icon} title={ariaLabel} @@ -257,6 +290,9 @@ export default class Dropdown extends React.PureComponent { size={size} ref={this.setTargetRef} onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} + onKeyPress={this.handleKeyPress} /> <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}> diff --git a/app/javascript/flavours/glitch/components/error_boundary.js b/app/javascript/flavours/glitch/components/error_boundary.js index 142a0c21a..62950a7d3 100644 --- a/app/javascript/flavours/glitch/components/error_boundary.js +++ b/app/javascript/flavours/glitch/components/error_boundary.js @@ -50,43 +50,43 @@ export default class ErrorBoundary extends React.PureComponent { <h1><FormattedMessage id='web_app_crash.title' defaultMessage="We're sorry, but something went wrong with the Mastodon app." /></h1> <p> <FormattedMessage id='web_app_crash.content' defaultMessage='You could try any of the following:' /> - <ul> - <li> - <FormattedMessage - id='web_app_crash.report_issue' - defaultMessage='Report a bug in the {issuetracker}' - values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }} - /> - { debugInfo !== '' && ( - <details> - <summary><FormattedMessage id='web_app_crash.debug_info' defaultMessage='Debug information' /></summary> - <textarea - className='web_app_crash-stacktrace' - value={debugInfo} - rows='10' - readOnly - /> - </details> - )} - </li> + </p> + <ul> + <li> + <FormattedMessage + id='web_app_crash.report_issue' + defaultMessage='Report a bug in the {issuetracker}' + values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener noreferrer' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }} + /> + { debugInfo !== '' && ( + <details> + <summary><FormattedMessage id='web_app_crash.debug_info' defaultMessage='Debug information' /></summary> + <textarea + className='web_app_crash-stacktrace' + value={debugInfo} + rows='10' + readOnly + /> + </details> + )} + </li> + <li> + <FormattedMessage + id='web_app_crash.reload_page' + defaultMessage='{reload} the current page' + values={{ reload: <a href='#' onClick={this.handleReload}><FormattedMessage id='web_app_crash.reload' defaultMessage='Reload' /></a> }} + /> + </li> + { preferencesLink !== undefined && ( <li> <FormattedMessage - id='web_app_crash.reload_page' - defaultMessage='{reload} the current page' - values={{ reload: <a href='#' onClick={this.handleReload}><FormattedMessage id='web_app_crash.reload' defaultMessage='Reload' /></a> }} + id='web_app_crash.change_your_settings' + defaultMessage='Change your {settings}' + values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }} /> </li> - { preferencesLink !== undefined && ( - <li> - <FormattedMessage - id='web_app_crash.change_your_settings' - defaultMessage='Change your {settings}' - values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }} - /> - </li> - )} - </ul> - </p> + )} + </ul> </div> </div> ); diff --git a/app/javascript/flavours/glitch/components/extended_video_player.js b/app/javascript/flavours/glitch/components/extended_video_player.js deleted file mode 100644 index 009c0d559..000000000 --- a/app/javascript/flavours/glitch/components/extended_video_player.js +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -export default class ExtendedVideoPlayer extends React.PureComponent { - - static propTypes = { - src: PropTypes.string.isRequired, - alt: PropTypes.string, - width: PropTypes.number, - height: PropTypes.number, - time: PropTypes.number, - controls: PropTypes.bool.isRequired, - muted: PropTypes.bool.isRequired, - onClick: PropTypes.func, - }; - - handleLoadedData = () => { - if (this.props.time) { - this.video.currentTime = this.props.time; - } - } - - componentDidMount () { - this.video.addEventListener('loadeddata', this.handleLoadedData); - } - - componentWillUnmount () { - this.video.removeEventListener('loadeddata', this.handleLoadedData); - } - - setRef = (c) => { - this.video = c; - } - - handleClick = e => { - e.stopPropagation(); - const handler = this.props.onClick; - if (handler) handler(); - } - - render () { - const { src, muted, controls, alt } = this.props; - - return ( - <div className='extended-video-player'> - <video - ref={this.setRef} - src={src} - autoPlay - role='button' - tabIndex='0' - aria-label={alt} - title={alt} - muted={muted} - controls={controls} - loop={!controls} - onClick={this.handleClick} - /> - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/components/gifv.js b/app/javascript/flavours/glitch/components/gifv.js new file mode 100644 index 000000000..83cfae49c --- /dev/null +++ b/app/javascript/flavours/glitch/components/gifv.js @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class GIFV extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + }; + + state = { + loading: true, + }; + + handleLoadedData = () => { + this.setState({ loading: false }); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.src !== this.props.src) { + this.setState({ loading: true }); + } + } + + handleClick = e => { + const { onClick } = this.props; + + if (onClick) { + e.stopPropagation(); + onClick(); + } + } + + render () { + const { src, width, height, alt } = this.props; + const { loading } = this.state; + + return ( + <div className='gifv' style={{ position: 'relative' }}> + {loading && ( + <canvas + width={width} + height={height} + role='button' + tabIndex='0' + aria-label={alt} + title={alt} + onClick={this.handleClick} + /> + )} + + <video + src={src} + width={width} + height={height} + role='button' + tabIndex='0' + aria-label={alt} + title={alt} + muted + loop + autoPlay + playsInline + onClick={this.handleClick} + onLoadedData={this.handleLoadedData} + style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }} + /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/hashtag.js b/app/javascript/flavours/glitch/components/hashtag.js index d75edd994..d42bee0e9 100644 --- a/app/javascript/flavours/glitch/components/hashtag.js +++ b/app/javascript/flavours/glitch/components/hashtag.js @@ -12,11 +12,11 @@ const Hashtag = ({ hashtag }) => ( #<span>{hashtag.get('name')}</span> </Permalink> - <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} /> + <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1, count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)}</strong> }} /> </div> <div className='trends__item__current'> - {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))} + {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)} </div> <div className='trends__item__sparkline'> diff --git a/app/javascript/flavours/glitch/components/icon.js b/app/javascript/flavours/glitch/components/icon.js index 8f55a0115..d8a17722f 100644 --- a/app/javascript/flavours/glitch/components/icon.js +++ b/app/javascript/flavours/glitch/components/icon.js @@ -1,26 +1,21 @@ -// Package imports. -import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; -// This just renders a FontAwesome icon. -export default function Icon ({ - className, - fullwidth, - icon, -}) { - const computedClass = classNames('icon', 'fa', { 'fa-fw': fullwidth }, `fa-${icon}`, className); - return icon ? ( - <span - aria-hidden='true' - className={computedClass} - /> - ) : null; -} +export default class Icon extends React.PureComponent { -// Props. -Icon.propTypes = { - className: PropTypes.string, - fullwidth: PropTypes.bool, - icon: PropTypes.string, -}; + static propTypes = { + id: PropTypes.string.isRequired, + className: PropTypes.string, + fixedWidth: PropTypes.bool, + }; + + render () { + const { id, className, fixedWidth, ...other } = this.props; + + return ( + <i role='img' className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })} {...other} /> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index 6a25794d3..e134d0a39 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -3,6 +3,7 @@ import Motion from 'flavours/glitch/util/optional_motion'; import spring from 'react-motion/lib/spring'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; export default class IconButton extends React.PureComponent { @@ -11,6 +12,9 @@ export default class IconButton extends React.PureComponent { title: PropTypes.string.isRequired, icon: PropTypes.string.isRequired, onClick: PropTypes.func, + onMouseDown: PropTypes.func, + onKeyDown: PropTypes.func, + onKeyPress: PropTypes.func, size: PropTypes.number, active: PropTypes.bool, pressed: PropTypes.bool, @@ -20,7 +24,6 @@ export default class IconButton extends React.PureComponent { disabled: PropTypes.bool, inverted: PropTypes.bool, animate: PropTypes.bool, - flip: PropTypes.bool, overlay: PropTypes.bool, tabIndex: PropTypes.string, label: PropTypes.string, @@ -35,6 +38,21 @@ export default class IconButton extends React.PureComponent { tabIndex: '0', }; + state = { + activate: false, + deactivate: false, + } + + componentWillReceiveProps (nextProps) { + if (!nextProps.animate) return; + + if (this.props.active && !nextProps.active) { + this.setState({ activate: false, deactivate: true }); + } else if (!this.props.active && nextProps.active) { + this.setState({ activate: true, deactivate: false }); + } + } + handleClick = (e) => { e.preventDefault(); @@ -43,6 +61,24 @@ export default class IconButton extends React.PureComponent { } } + handleKeyPress = (e) => { + if (this.props.onKeyPress && !this.props.disabled) { + this.props.onKeyPress(e); + } + } + + handleMouseDown = (e) => { + if (!this.props.disabled && this.props.onMouseDown) { + this.props.onMouseDown(e); + } + } + + handleKeyDown = (e) => { + if (!this.props.disabled && this.props.onKeyDown) { + this.props.onKeyDown(e); + } + } + render () { let style = { fontSize: `${this.props.size}px`, @@ -59,80 +95,49 @@ export default class IconButton extends React.PureComponent { const { active, - animate, className, disabled, expanded, icon, inverted, - flip, overlay, pressed, tabIndex, title, } = this.props; + const { + activate, + deactivate, + } = this.state; + const classes = classNames(className, 'icon-button', { active, disabled, inverted, + activate, + deactivate, overlayed: overlay, }); - const flipDeg = flip ? -180 : -360; - const rotateDeg = active ? flipDeg : 0; - - const motionDefaultStyle = { - rotate: rotateDeg, - }; - - const springOpts = { - stiffness: this.props.flip ? 60 : 120, - damping: 7, - }; - const motionStyle = { - rotate: animate ? spring(rotateDeg, springOpts) : 0, - }; - - if (!animate) { - // Perf optimization: avoid unnecessary <Motion> components unless - // we actually need to animate. - return ( - <button - aria-label={title} - aria-pressed={pressed} - aria-expanded={expanded} - title={title} - className={classes} - onClick={this.handleClick} - style={style} - tabIndex={tabIndex} - disabled={disabled} - > - <i className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> - </button> - ); - } - return ( - <Motion defaultStyle={motionDefaultStyle} style={motionStyle}> - {({ rotate }) => - (<button - aria-label={title} - aria-pressed={pressed} - aria-expanded={expanded} - title={title} - className={classes} - onClick={this.handleClick} - style={style} - tabIndex={tabIndex} - disabled={disabled} - > - <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> - {this.props.label} - </button>) - } - </Motion> + <button + aria-label={title} + aria-pressed={pressed} + aria-expanded={expanded} + title={title} + className={classes} + onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleKeyDown} + onKeyPress={this.handleKeyPress} + style={style} + tabIndex={tabIndex} + disabled={disabled} + > + <Icon id={icon} fixedWidth aria-hidden='true' /> + {this.props.label} + </button> ); } diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.js b/app/javascript/flavours/glitch/components/icon_with_badge.js index 4a15ee5b4..219efc28c 100644 --- a/app/javascript/flavours/glitch/components/icon_with_badge.js +++ b/app/javascript/flavours/glitch/components/icon_with_badge.js @@ -6,7 +6,7 @@ const formatNumber = num => num > 40 ? '40+' : num; const IconWithBadge = ({ id, count, className }) => ( <i className='icon-with-badge'> - <Icon icon={id} fixedWidth className={className} /> + <Icon id={id} fixedWidth className={className} /> {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>} </i> ); diff --git a/app/javascript/flavours/glitch/components/intersection_observer_article.js b/app/javascript/flavours/glitch/components/intersection_observer_article.js index 900c98638..03b3700df 100644 --- a/app/javascript/flavours/glitch/components/intersection_observer_article.js +++ b/app/javascript/flavours/glitch/components/intersection_observer_article.js @@ -1,10 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; -import ImmutablePureComponent from 'react-immutable-pure-component'; import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task'; import getRectFromEntry from 'flavours/glitch/util/get_rect_from_entry'; -export default class IntersectionObserverArticle extends ImmutablePureComponent { +// Diff these props in the "unrendered" state +const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; + +export default class IntersectionObserverArticle extends React.Component { static propTypes = { intersectionObserverWrapper: PropTypes.object.isRequired, @@ -22,20 +24,21 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent } shouldComponentUpdate (nextProps, nextState) { - if (!nextState.isIntersecting && nextState.isHidden) { - // It's only if we're not intersecting (i.e. offscreen) and isHidden is true - // that either "isIntersecting" or "isHidden" matter, and then they're - // the only things that matter (and updated ARIA attributes). - return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength; - } else if (nextState.isIntersecting && !this.state.isIntersecting) { - // If we're going from a non-intersecting state to an intersecting state, - // (i.e. offscreen to onscreen), then we definitely need to re-render + const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight); + const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight); + if (!!isUnrendered !== !!willBeUnrendered) { + // If we're going from rendered to unrendered (or vice versa) then update return true; } - // Otherwise, diff based on "updateOnProps" and "updateOnStates" - return super.shouldComponentUpdate(nextProps, nextState); + // If we are and remain hidden, diff based on props + if (isUnrendered) { + return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]); + } + // Else, assume the children have changed + return true; } + componentDidMount () { const { intersectionObserverWrapper, id } = this.props; @@ -119,7 +122,7 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent data-id={id} tabIndex='0' style={style}> - {children && React.cloneElement(children, { hidden: !isIntersecting && (isHidden || cachedHeight) })} + {children && React.cloneElement(children, { hidden: !isIntersecting && (isHidden || !!cachedHeight) })} </article> ); } diff --git a/app/javascript/flavours/glitch/components/load_gap.js b/app/javascript/flavours/glitch/components/load_gap.js index 012303ae1..fe3f60a58 100644 --- a/app/javascript/flavours/glitch/components/load_gap.js +++ b/app/javascript/flavours/glitch/components/load_gap.js @@ -1,13 +1,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import { injectIntl, defineMessages } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; const messages = defineMessages({ load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, }); -@injectIntl -export default class LoadGap extends React.PureComponent { +export default @injectIntl +class LoadGap extends React.PureComponent { static propTypes = { disabled: PropTypes.bool, @@ -25,7 +26,7 @@ export default class LoadGap extends React.PureComponent { return ( <button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}> - <i className='fa fa-ellipsis-h' /> + <Icon id='ellipsis-h' /> </button> ); } diff --git a/app/javascript/flavours/glitch/components/load_pending.js b/app/javascript/flavours/glitch/components/load_pending.js new file mode 100644 index 000000000..7e2702403 --- /dev/null +++ b/app/javascript/flavours/glitch/components/load_pending.js @@ -0,0 +1,22 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class LoadPending extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func, + count: PropTypes.number, + } + + render() { + const { count } = this.props; + + return ( + <button className='load-more load-gap' onClick={this.props.onClick}> + <FormattedMessage id='load_pending' defaultMessage='{count, plural, one {# new item} other {# new items}}' values={{ count }} /> + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js index 6ef101f11..85ee79e11 100644 --- a/app/javascript/flavours/glitch/components/media_gallery.js +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -6,7 +6,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from 'flavours/glitch/util/is_mobile'; import classNames from 'classnames'; -import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state'; +import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/util/initial_state'; import { decode } from 'blurhash'; const messages = defineMessages({ @@ -101,6 +101,8 @@ class Item extends React.PureComponent { } _decode () { + if (!useBlurhash) return; + const hash = this.props.attachment.get('blurhash'); const pixels = decode(hash, 32, 32); @@ -177,7 +179,7 @@ class Item extends React.PureComponent { if (attachment.get('type') === 'unknown') { return ( <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> - <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}> + <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} target='_blank' rel='noopener noreferrer'> <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' /> </a> </div> @@ -205,6 +207,7 @@ class Item extends React.PureComponent { href={attachment.get('remote_url') || originalUrl} onClick={this.handleClick} target='_blank' + rel='noopener noreferrer' > <img className={letterbox ? 'letterbox' : null} @@ -252,8 +255,8 @@ class Item extends React.PureComponent { } -@injectIntl -export default class MediaGallery extends React.PureComponent { +export default @injectIntl +class MediaGallery extends React.PureComponent { static propTypes = { sensitive: PropTypes.bool, @@ -327,7 +330,8 @@ export default class MediaGallery extends React.PureComponent { render () { const { media, intl, sensitive, letterbox, fullwidth, defaultWidth } = this.props; const { visible } = this.state; - const size = media.take(4).size; + const size = media.take(4).size; + const uncached = media.every(attachment => attachment.get('type') === 'unknown'); const width = this.state.width || defaultWidth; @@ -348,10 +352,16 @@ export default class MediaGallery extends React.PureComponent { if (this.isStandaloneEligible()) { children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />; } else { - children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} visible={visible} />); + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} visible={visible || uncached} />); } - if (visible) { + if (uncached) { + spoilerButton = ( + <button type='button' disabled className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'><FormattedMessage id='status.uncached_media_warning' defaultMessage='Not available' /></span> + </button> + ); + } else if (visible) { spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />; } else { spoilerButton = ( @@ -363,7 +373,7 @@ export default class MediaGallery extends React.PureComponent { return ( <div className={computedClass} style={style} ref={this.handleRef}> - <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}> + <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}> {spoilerButton} {visible && sensitive && ( <span className='sensitive-marker'> diff --git a/app/javascript/flavours/glitch/components/missing_indicator.js b/app/javascript/flavours/glitch/components/missing_indicator.js index 70d8c3b98..ee5bf7c1e 100644 --- a/app/javascript/flavours/glitch/components/missing_indicator.js +++ b/app/javascript/flavours/glitch/components/missing_indicator.js @@ -1,17 +1,24 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; +import illustration from 'flavours/glitch/images/elephant_ui_disappointed.svg'; +import classNames from 'classnames'; -const MissingIndicator = () => ( - <div className='regeneration-indicator missing-indicator'> - <div> - <div className='regeneration-indicator__figure' /> +const MissingIndicator = ({ fullPage }) => ( + <div className={classNames('regeneration-indicator', { 'regeneration-indicator--without-header': fullPage })}> + <div className='regeneration-indicator__figure'> + <img src={illustration} alt='' /> + </div> - <div className='regeneration-indicator__label'> - <FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' /> - <FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' /> - </div> + <div className='regeneration-indicator__label'> + <FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' /> + <FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' /> </div> </div> ); +MissingIndicator.propTypes = { + fullPage: PropTypes.bool, +}; + export default MissingIndicator; diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js index 4e8648b49..f9877d5ea 100644 --- a/app/javascript/flavours/glitch/components/modal_root.js +++ b/app/javascript/flavours/glitch/components/modal_root.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import 'wicg-inert'; import createHistory from 'history/createBrowserHistory'; export default class ModalRoot extends React.PureComponent { @@ -26,8 +27,30 @@ export default class ModalRoot extends React.PureComponent { } } + handleKeyDown = (e) => { + if (e.key === 'Tab') { + const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none'); + const index = focusable.indexOf(e.target); + + let element; + + if (e.shiftKey) { + element = focusable[index - 1] || focusable[focusable.length - 1]; + } else { + element = focusable[index + 1] || focusable[0]; + } + + if (element) { + element.focus(); + e.stopPropagation(); + e.preventDefault(); + } + } + } + componentDidMount () { window.addEventListener('keyup', this.handleKeyUp, false); + window.addEventListener('keydown', this.handleKeyDown, false); this.history = this.context.router ? this.context.router.history : createHistory(); } @@ -39,15 +62,22 @@ export default class ModalRoot extends React.PureComponent { } else if (!nextProps.children) { this.setState({ revealed: false }); } - if (!nextProps.children && !!this.props.children) { - this.activeElement.focus({ preventScroll: true }); - this.activeElement = null; - } } componentDidUpdate (prevProps) { if (!this.props.children && !!prevProps.children) { this.getSiblings().forEach(sibling => sibling.removeAttribute('inert')); + + // Because of the wicg-inert polyfill, the activeElement may not be + // immediately selectable, we have to wait for observers to run, as + // described in https://github.com/WICG/inert#performance-and-gotchas + Promise.resolve().then(() => { + this.activeElement.focus({ preventScroll: true }); + this.activeElement = null; + }).catch((error) => { + console.error(error); + }); + this.handleModalClose(); } if (this.props.children) { @@ -60,6 +90,7 @@ export default class ModalRoot extends React.PureComponent { componentWillUnmount () { window.removeEventListener('keyup', this.handleKeyUp); + window.removeEventListener('keydown', this.handleKeyDown); } handleModalClose () { diff --git a/app/javascript/flavours/glitch/components/notification_purge_buttons.js b/app/javascript/flavours/glitch/components/notification_purge_buttons.js index e0c1543b0..3c7d67109 100644 --- a/app/javascript/flavours/glitch/components/notification_purge_buttons.js +++ b/app/javascript/flavours/glitch/components/notification_purge_buttons.js @@ -10,6 +10,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import Icon from 'flavours/glitch/components/icon'; const messages = defineMessages({ btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' }, @@ -18,8 +19,8 @@ const messages = defineMessages({ btnApply : { id: 'notification_purge.btn_apply', defaultMessage: 'Clear\nselected' }, }); -@injectIntl -export default class NotificationPurgeButtons extends ImmutablePureComponent { +export default @injectIntl +class NotificationPurgeButtons extends ImmutablePureComponent { static propTypes = { onDeleteMarked : PropTypes.func.isRequired, @@ -49,7 +50,7 @@ export default class NotificationPurgeButtons extends ImmutablePureComponent { </button> <button onClick={this.props.onDeleteMarked}> - <i className='fa fa-trash' /><br />{intl.formatMessage(messages.btnApply)} + <Icon id='trash' /><br />{intl.formatMessage(messages.btnApply)} </button> </div> ); diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js index 690f9ae5a..62965df94 100644 --- a/app/javascript/flavours/glitch/components/poll.js +++ b/app/javascript/flavours/glitch/components/poll.js @@ -4,15 +4,17 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; -import { vote, fetchPoll } from 'mastodon/actions/polls'; -import Motion from 'mastodon/features/ui/util/optional_motion'; +import { vote, fetchPoll } from 'flavours/glitch/actions/polls'; +import Motion from 'flavours/glitch/util/optional_motion'; import spring from 'react-motion/lib/spring'; import escapeTextContentForBrowser from 'escape-html'; -import emojify from 'mastodon/features/emoji/emoji'; +import emojify from 'flavours/glitch/util/emoji'; import RelativeTimestamp from './relative_timestamp'; +import Icon from 'flavours/glitch/components/icon'; const messages = defineMessages({ closed: { id: 'poll.closed', defaultMessage: 'Closed' }, + voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' }, }); const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => { @@ -32,11 +34,40 @@ class Poll extends ImmutablePureComponent { state = { selected: {}, + expired: null, }; - handleOptionChange = e => { - const { target: { value } } = e; + static getDerivedStateFromProps (props, state) { + const { poll, intl } = props; + const expires_at = poll.get('expires_at'); + const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now(); + return (expired === state.expired) ? null : { expired }; + } + + componentDidMount () { + this._setupTimer(); + } + + componentDidUpdate () { + this._setupTimer(); + } + + componentWillUnmount () { + clearTimeout(this._timer); + } + + _setupTimer () { + const { poll, intl } = this.props; + clearTimeout(this._timer); + if (!this.state.expired) { + const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now(); + this._timer = setTimeout(() => { + this.setState({ expired: true }); + }, delay); + } + } + _toggleOption = value => { if (this.props.poll.get('multiple')) { const tmp = { ...this.state.selected }; if (tmp[value]) { @@ -50,8 +81,20 @@ class Poll extends ImmutablePureComponent { tmp[value] = true; this.setState({ selected: tmp }); } + } + + handleOptionChange = ({ target: { value } }) => { + this._toggleOption(value); }; + handleOptionKeyPress = (e) => { + if (e.key === 'Enter' || e.key === ' ') { + this._toggleOption(e.target.getAttribute('data-index')); + e.stopPropagation(); + e.preventDefault(); + } + } + handleVote = () => { if (this.props.disabled) { return; @@ -68,12 +111,13 @@ class Poll extends ImmutablePureComponent { this.props.dispatch(fetchPoll(this.props.poll.get('id'))); }; - renderOption (option, optionIndex) { - const { poll, disabled } = this.props; - const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100; - const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count')); - const active = !!this.state.selected[`${optionIndex}`]; - const showResults = poll.get('voted') || poll.get('expired'); + renderOption (option, optionIndex, showResults) { + const { poll, disabled, intl } = this.props; + const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); + const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100; + const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count')); + const active = !!this.state.selected[`${optionIndex}`]; + const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex)); let titleEmojified = option.get('title_emojified'); if (!titleEmojified) { @@ -101,8 +145,21 @@ class Poll extends ImmutablePureComponent { disabled={disabled} /> - {!showResults && <span className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} />} - {showResults && <span className='poll__number'>{Math.round(percent)}%</span>} + {!showResults && ( + <span + className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} + tabIndex='0' + role={poll.get('multiple') ? 'checkbox' : 'radio'} + onKeyPress={this.handleOptionKeyPress} + aria-checked={active} + aria-label={option.get('title')} + data-index={optionIndex} + /> + )} + {showResults && <span className='poll__number'> + {!!voted && <Icon id='check' className='poll__vote__mark' title={intl.formatMessage(messages.voted)} />} + {Math.round(percent)}% + </span>} <span dangerouslySetInnerHTML={{ __html: titleEmojified }} /> </label> @@ -112,25 +169,34 @@ class Poll extends ImmutablePureComponent { render () { const { poll, intl } = this.props; + const { expired } = this.state; if (!poll) { return null; } - const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />; - const showResults = poll.get('voted') || poll.get('expired'); + const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />; + const showResults = poll.get('voted') || expired; const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); + let votesCount = null; + + if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) { + votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />; + } else { + votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />; + } + return ( <div className='poll'> <ul> - {poll.get('options').map((option, i) => this.renderOption(option, i))} + {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))} </ul> <div className='poll__footer'> {!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>} {showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>} - <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} /> + {votesCount} {poll.get('expires_at') && <span> · {timeRemaining}</span>} </div> </div> diff --git a/app/javascript/flavours/glitch/components/radio_button.js b/app/javascript/flavours/glitch/components/radio_button.js new file mode 100644 index 000000000..0496fa286 --- /dev/null +++ b/app/javascript/flavours/glitch/components/radio_button.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class RadioButton extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + checked: PropTypes.bool, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + label: PropTypes.node.isRequired, + }; + + render () { + const { name, value, checked, onChange, label } = this.props; + + return ( + <label className='radio-button'> + <input + name={name} + type='radio' + value={value} + checked={checked} + onChange={onChange} + /> + + <span className={classNames('radio-button__input', { checked })} /> + + <span>{label}</span> + </label> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/regeneration_indicator.js b/app/javascript/flavours/glitch/components/regeneration_indicator.js new file mode 100644 index 000000000..f4e0a79ef --- /dev/null +++ b/app/javascript/flavours/glitch/components/regeneration_indicator.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import illustration from 'flavours/glitch/images/elephant_ui_working.svg'; + +const MissingIndicator = () => ( + <div className='regeneration-indicator'> + <div className='regeneration-indicator__figure'> + <img src={illustration} alt='' /> + </div> + + <div className='regeneration-indicator__label'> + <FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading…' /> + <FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' /> + </div> + </div> +); + +export default MissingIndicator; diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js index 462185bbc..c022290a4 100644 --- a/app/javascript/flavours/glitch/components/scrollable_list.js +++ b/app/javascript/flavours/glitch/components/scrollable_list.js @@ -3,6 +3,7 @@ import { ScrollContainer } from 'react-router-scroll-4'; import PropTypes from 'prop-types'; import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container'; import LoadMore from './load_more'; +import LoadPending from './load_pending'; import IntersectionObserverWrapper from 'flavours/glitch/util/intersection_observer_wrapper'; import { throttle } from 'lodash'; import { List as ImmutableList } from 'immutable'; @@ -21,6 +22,7 @@ export default class ScrollableList extends PureComponent { static propTypes = { scrollKey: PropTypes.string.isRequired, onLoadMore: PropTypes.func, + onLoadPending: PropTypes.func, onScrollToTop: PropTypes.func, onScroll: PropTypes.func, trackScroll: PropTypes.bool, @@ -28,10 +30,12 @@ export default class ScrollableList extends PureComponent { isLoading: PropTypes.bool, showLoading: PropTypes.bool, hasMore: PropTypes.bool, + numPending: PropTypes.number, prepend: PropTypes.node, alwaysPrepend: PropTypes.bool, emptyMessage: PropTypes.node, children: PropTypes.node, + bindToDocument: PropTypes.bool, }; static defaultProps = { @@ -47,7 +51,9 @@ export default class ScrollableList extends PureComponent { handleScroll = throttle(() => { if (this.node) { - const { scrollTop, scrollHeight, clientHeight } = this.node; + const scrollTop = this.getScrollTop(); + const scrollHeight = this.getScrollHeight(); + const clientHeight = this.getClientHeight(); const offset = scrollHeight - scrollTop - clientHeight; if (400 > offset && this.props.onLoadMore && this.props.hasMore && !this.props.isLoading) { @@ -77,9 +83,14 @@ export default class ScrollableList extends PureComponent { scrollToTopOnMouseIdle = false; setScrollTop = newScrollTop => { - if (this.node.scrollTop !== newScrollTop) { + if (this.getScrollTop() !== newScrollTop) { this.lastScrollWasSynthetic = true; - this.node.scrollTop = newScrollTop; + + if (this.props.bindToDocument) { + document.scrollingElement.scrollTop = newScrollTop; + } else { + this.node.scrollTop = newScrollTop; + } } }; @@ -97,7 +108,7 @@ export default class ScrollableList extends PureComponent { this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY); - if (!this.mouseMovedRecently && this.node.scrollTop === 0) { + if (!this.mouseMovedRecently && this.getScrollTop() === 0) { // Only set if we just started moving and are scrolled to the top. this.scrollToTopOnMouseIdle = true; } @@ -129,15 +140,27 @@ export default class ScrollableList extends PureComponent { } getScrollPosition = () => { - if (this.node && (this.node.scrollTop > 0 || this.mouseMovedRecently)) { - return {height: this.node.scrollHeight, top: this.node.scrollTop}; + if (this.node && (this.getScrollTop() > 0 || this.mouseMovedRecently)) { + return { height: this.getScrollHeight(), top: this.getScrollTop() }; } else { return null; } } + getScrollTop = () => { + return this.props.bindToDocument ? document.scrollingElement.scrollTop : this.node.scrollTop; + } + + getScrollHeight = () => { + return this.props.bindToDocument ? document.scrollingElement.scrollHeight : this.node.scrollHeight; + } + + getClientHeight = () => { + return this.props.bindToDocument ? document.scrollingElement.clientHeight : this.node.clientHeight; + } + updateScrollBottom = (snapshot) => { - const newScrollTop = this.node.scrollHeight - snapshot; + const newScrollTop = this.getScrollHeight() - snapshot; this.setScrollTop(newScrollTop); } @@ -150,8 +173,10 @@ export default class ScrollableList extends PureComponent { const someItemInserted = React.Children.count(prevProps.children) > 0 && React.Children.count(prevProps.children) < React.Children.count(this.props.children) && this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props); - if (someItemInserted && (this.node.scrollTop > 0 || this.mouseMovedRecently)) { - return this.node.scrollHeight - this.node.scrollTop; + const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0); + + if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently)) { + return this.getScrollHeight() - this.getScrollTop(); } else { return null; } @@ -160,7 +185,9 @@ export default class ScrollableList extends PureComponent { componentDidUpdate (prevProps, prevState, snapshot) { // Reset the scroll position when a new child comes in in order not to // jerk the scrollbar around if you're already scrolled down the page. - if (snapshot !== null) this.updateScrollBottom(snapshot); + if (snapshot !== null) { + this.updateScrollBottom(snapshot); + } } componentWillUnmount () { @@ -186,13 +213,23 @@ export default class ScrollableList extends PureComponent { } attachScrollListener () { - this.node.addEventListener('scroll', this.handleScroll); - this.node.addEventListener('wheel', this.handleWheel); + if (this.props.bindToDocument) { + document.addEventListener('scroll', this.handleScroll); + document.addEventListener('wheel', this.handleWheel); + } else { + this.node.addEventListener('scroll', this.handleScroll); + this.node.addEventListener('wheel', this.handleWheel); + } } detachScrollListener () { - this.node.removeEventListener('scroll', this.handleScroll); - this.node.removeEventListener('wheel', this.handleWheel); + if (this.props.bindToDocument) { + document.removeEventListener('scroll', this.handleScroll); + document.removeEventListener('wheel', this.handleWheel); + } else { + this.node.removeEventListener('scroll', this.handleScroll); + this.node.removeEventListener('wheel', this.handleWheel); + } } getFirstChildKey (props) { @@ -222,12 +259,25 @@ export default class ScrollableList extends PureComponent { return !(location.state && location.state.mastodonModalOpen); } + handleLoadPending = e => { + e.preventDefault(); + this.props.onLoadPending(); + // Prevent the weird scroll-jumping behavior, as we explicitly don't want to + // scroll to top, and we know the scroll height is going to change + this.scrollToTopOnMouseIdle = false; + this.lastScrollWasSynthetic = false; + this.clearMouseIdleTimer(); + this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY); + this.mouseMovedRecently = true; + } + render () { - const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props; + const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = React.Children.count(children); const loadMore = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; + const loadPending = (numPending > 0) ? <LoadPending count={numPending} onClick={this.handleLoadPending} /> : null; let scrollableArea = null; if (showLoading) { @@ -248,6 +298,8 @@ export default class ScrollableList extends PureComponent { <div role='feed' className='item-list'> {prepend} + {loadPending} + {React.Children.map(this.props.children, (child, index) => ( <IntersectionObserverArticleContainer key={child.key} diff --git a/app/javascript/flavours/glitch/components/spoilers.js b/app/javascript/flavours/glitch/components/spoilers.js new file mode 100644 index 000000000..8527403c1 --- /dev/null +++ b/app/javascript/flavours/glitch/components/spoilers.js @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +export default +class Spoilers extends React.PureComponent { + static propTypes = { + spoilerText: PropTypes.string, + children: PropTypes.node, + }; + + state = { + hidden: true, + } + + handleSpoilerClick = () => { + this.setState({ hidden: !this.state.hidden }); + } + + render () { + const { spoilerText, children } = this.props; + const { hidden } = this.state; + + const toggleText = hidden ? + <FormattedMessage + id='status.show_more' + defaultMessage='Show more' + key='0' + /> : + <FormattedMessage + id='status.show_less' + defaultMessage='Show less' + key='0' + />; + + return ([ + <p className='spoiler__text'> + {spoilerText} + {' '} + <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}> + {toggleText} + </button> + </p>, + <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}> + {children} + </div> + ]); + } +} + diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index f6d73475a..23cdc0167 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -10,7 +10,7 @@ import AttachmentList from './attachment_list'; import Card from '../features/status/components/card'; import { injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { MediaGallery, Video } from 'flavours/glitch/util/async-components'; +import { MediaGallery, Video, Audio } from 'flavours/glitch/util/async-components'; import { HotKeys } from 'react-hotkeys'; import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container'; import classNames from 'classnames'; @@ -55,8 +55,8 @@ export const defaultMediaVisibility = (status, settings) => { return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); } -@injectIntl -export default class Status extends ImmutablePureComponent { +export default @injectIntl +class Status extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -105,6 +105,8 @@ export default class Status extends ImmutablePureComponent { showMedia: undefined, statusId: undefined, revealBehindCW: undefined, + showCard: false, + forceFilter: undefined, } // Avoid checking props that are functions (and whose equality will always @@ -125,6 +127,7 @@ export default class Status extends ImmutablePureComponent { 'isExpanded', 'isCollapsed', 'showMedia', + 'forceFilter', ] // If our settings have changed to disable collapsed statuses, then we @@ -255,28 +258,32 @@ export default class Status extends ImmutablePureComponent { this.setState({ autoCollapsed: true }); } - this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'); + // Hack to fix timeline jumps when a preview card is fetched + this.setState({ + showCard: !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'), + }); } + // Hack to fix timeline jumps on second rendering when auto-collapsing + // or on subsequent rendering when a preview card has been fetched getSnapshotBeforeUpdate (prevProps, prevState) { - if (this.props.getScrollPosition) { + if (!this.props.getScrollPosition) return null; + + const { muted, hidden, status, settings } = this.props; + + const doShowCard = !muted && !hidden && status && status.get('card') && settings.get('inline_preview_cards'); + if (this.state.autoCollapsed || (doShowCard && !this.state.showCard)) { + if (doShowCard) this.setState({ showCard: true }); + if (this.state.autoCollapsed) this.setState({ autoCollapsed: false }); return this.props.getScrollPosition(); } else { return null; } } - // Hack to fix timeline jumps on second rendering when auto-collapsing componentDidUpdate (prevProps, prevState, snapshot) { - const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'); - if (this.state.autoCollapsed || (doShowCard && !this.didShowCard)) { - if (doShowCard) this.didShowCard = true; - if (this.state.autoCollapsed) this.setState({ autoCollapsed: false }); - if (snapshot !== null && this.props.updateScrollBottom) { - if (this.node.offsetTop < snapshot.top) { - this.props.updateScrollBottom(snapshot.height - snapshot.top); - } - } + if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) { + this.props.updateScrollBottom(snapshot.height - snapshot.top); } } @@ -369,6 +376,22 @@ export default class Status extends ImmutablePureComponent { this.props.onOpenVideo(media, startTime); } + handleHotkeyOpenMedia = e => { + const { status, onOpenMedia, onOpenVideo } = this.props; + + e.preventDefault(); + + if (status.get('media_attachments').size > 0) { + if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + // TODO: toggle play/paused? + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + onOpenVideo(status.getIn(['media_attachments', 0]), 0); + } else { + onOpenMedia(status.get('media_attachments'), 0); + } + } + } + handleHotkeyReply = e => { e.preventDefault(); this.props.onReply(this.props.status, this.context.router.history); @@ -422,16 +445,29 @@ export default class Status extends ImmutablePureComponent { this.handleToggleMediaVisibility(); } + handleUnfilterClick = e => { + const { onUnfilter, status } = this.props; + onUnfilter(status.get('reblog') ? status.get('reblog') : status, () => this.setState({ forceFilter: false })); + } + + handleFilterClick = () => { + this.setState({ forceFilter: true }); + } + handleRef = c => { this.node = c; } renderLoadingMediaGallery () { - return <div className='media_gallery' style={{ height: '110px' }} />; + return <div className='media-gallery' style={{ height: '110px' }} />; } renderLoadingVideoPlayer () { - return <div className='media-spoiler-video' style={{ height: '110px' }} />; + return <div className='video-player' style={{ height: '110px' }} />; + } + + renderLoadingAudioPlayer () { + return <div className='audio-player' style={{ height: '110px' }} />; } render () { @@ -460,7 +496,7 @@ export default class Status extends ImmutablePureComponent { featured, ...other } = this.props; - const { isExpanded, isCollapsed } = this.state; + const { isExpanded, isCollapsed, forceFilter } = this.state; let background = null; let attachments = null; let media = null; @@ -470,17 +506,36 @@ export default class Status extends ImmutablePureComponent { return null; } + const handlers = { + reply: this.handleHotkeyReply, + favourite: this.handleHotkeyFavourite, + boost: this.handleHotkeyBoost, + mention: this.handleHotkeyMention, + open: this.handleHotkeyOpen, + openProfile: this.handleHotkeyOpenProfile, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + toggleSpoiler: this.handleExpandedToggle, + bookmark: this.handleHotkeyBookmark, + toggleCollapse: this.handleHotkeyCollapse, + toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, + }; + if (hidden) { return ( - <div ref={this.handleRef}> - {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} - {' '} - {status.get('content')} - </div> + <HotKeys handlers={handlers}> + <div ref={this.handleRef} className='status focusable' tabIndex='0'> + {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} + {' '} + {status.get('content')} + </div> + </HotKeys> ); } - if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) { + const filtered = (status.get('filtered') || status.getIn(['reblog', 'filtered'])) && settings.get('filtering_behavior') !== 'content_warning'; + if (forceFilter === undefined ? filtered : forceFilter) { const minHandlers = this.props.muted ? {} : { moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, @@ -490,6 +545,12 @@ export default class Status extends ImmutablePureComponent { <HotKeys handlers={minHandlers}> <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}> <FormattedMessage id='status.filtered' defaultMessage='Filtered' /> + {settings.get('filtering_behavior') !== 'upstream' && ' '} + {settings.get('filtering_behavior') !== 'upstream' && ( + <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}> + <FormattedMessage id='status.show_filter_reason' defaultMessage='(show why)' /> + </button> + )} </div> </HotKeys> ); @@ -521,16 +582,33 @@ export default class Status extends ImmutablePureComponent { media={status.get('media_attachments')} /> ); - } else if (attachments.getIn([0, 'type']) === 'video') { // Media type is 'video' - const video = status.getIn(['media_attachments', 0]); + } else if (attachments.getIn([0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} > + {Component => ( + <Component + src={attachment.get('url')} + alt={attachment.get('description')} + duration={attachment.getIn(['meta', 'original', 'duration'], 0)} + peaks={[0]} + height={70} + /> + )} + </Bundle> + ); + mediaIcon = 'music'; + } else if (attachments.getIn([0, 'type']) === 'video') { + const attachment = status.getIn(['media_attachments', 0]); media = ( <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > {Component => (<Component - preview={video.get('preview_url')} - blurhash={video.get('blurhash')} - src={video.get('url')} - alt={video.get('description')} + preview={attachment.get('preview_url')} + blurhash={attachment.get('blurhash')} + src={attachment.get('url')} + alt={attachment.get('description')} inline sensitive={status.get('sensitive')} letterbox={settings.getIn(['media', 'letterbox'])} @@ -605,21 +683,6 @@ export default class Status extends ImmutablePureComponent { rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') }); } - const handlers = { - reply: this.handleHotkeyReply, - favourite: this.handleHotkeyFavourite, - boost: this.handleHotkeyBoost, - mention: this.handleHotkeyMention, - open: this.handleHotkeyOpen, - openProfile: this.handleHotkeyOpenProfile, - moveUp: this.handleHotkeyMoveUp, - moveDown: this.handleHotkeyMoveDown, - toggleSpoiler: this.handleExpandedToggle, - bookmark: this.handleHotkeyBookmark, - toggleCollapse: this.handleHotkeyCollapse, - toggleSensitive: this.handleHotkeyToggleSensitive, - }; - const computedClass = classNames('status', `status-${status.get('visibility')}`, { collapsed: isCollapsed, 'has-background': isCollapsed && background, @@ -676,6 +739,8 @@ export default class Status extends ImmutablePureComponent { onExpandedToggle={this.handleExpandedToggle} parseClick={parseClick} disabled={!router} + tagLinks={settings.get('tag_misleading_links')} + rewriteMentions={settings.get('rewrite_mentions')} /> {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( <StatusActionBar @@ -684,6 +749,7 @@ export default class Status extends ImmutablePureComponent { account={status.get('account')} showReplyCount={settings.get('show_reply_count')} directMessage={!!otherAccounts} + onFilter={this.handleFilterClick} /> ) : null} {notification ? ( diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index 85bc4a976..60b370622 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -35,6 +35,7 @@ const messages = defineMessages({ admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, + hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, }); const obfuscatedCount = count => { @@ -47,8 +48,8 @@ const obfuscatedCount = count => { } }; -@injectIntl -export default class StatusActionBar extends ImmutablePureComponent { +export default @injectIntl +class StatusActionBar extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -69,6 +70,7 @@ export default class StatusActionBar extends ImmutablePureComponent { onMuteConversation: PropTypes.func, onPin: PropTypes.func, onBookmark: PropTypes.func, + onFilter: PropTypes.func, withDismiss: PropTypes.bool, showReplyCount: PropTypes.bool, directMessage: PropTypes.bool, @@ -191,6 +193,10 @@ export default class StatusActionBar extends ImmutablePureComponent { } } + handleFilterClick = () => { + this.props.onFilter(); + } + render () { const { status, intl, withDismiss, showReplyCount, directMessage } = this.props; @@ -263,6 +269,10 @@ export default class StatusActionBar extends ImmutablePureComponent { <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} /> ); + const filterButton = status.get('filtered') && ( + <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} /> + ); + let replyButton = ( <IconButton className='status__action-bar-button' @@ -284,11 +294,12 @@ export default class StatusActionBar extends ImmutablePureComponent { <div className='status__action-bar'> {replyButton} {!directMessage && [ - <IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />, - <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />, + <IconButton key='reblog-button' className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />, + <IconButton key='favourite-button' className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />, shareButton, - <IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />, - <div className='status__action-bar-dropdown'> + <IconButton key='bookmark-button' className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />, + filterButton, + <div key='dropdown-button' className='status__action-bar-dropdown'> <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} /> </div>, ]} diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index 07a0d1d5d..2c79de4db 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -5,6 +5,55 @@ import { isRtl } from 'flavours/glitch/util/rtl'; import { FormattedMessage } from 'react-intl'; import Permalink from './permalink'; import classnames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; +import { decode as decodeIDNA } from 'flavours/glitch/util/idna'; + +const textMatchesTarget = (text, origin, host) => { + return (text === origin || text === host + || text.startsWith(origin + '/') || text.startsWith(host + '/') + || 'www.' + text === host || ('www.' + text).startsWith(host + '/')); +} + +const isLinkMisleading = (link) => { + let linkTextParts = []; + + // Reconstruct visible text, as we do not have much control over how links + // from remote software look, and we can't rely on `innerText` because the + // `invisible` class does not set `display` to `none`. + + const walk = (node) => { + switch (node.nodeType) { + case Node.TEXT_NODE: + linkTextParts.push(node.textContent); + break; + case Node.ELEMENT_NODE: + if (node.classList.contains('invisible')) return; + const children = node.childNodes; + for (let i = 0; i < children.length; i++) { + walk(children[i]); + } + break; + } + }; + + walk(link); + + const linkText = linkTextParts.join(''); + const targetURL = new URL(link.href); + + // The following may not work with international domain names + if (textMatchesTarget(linkText, targetURL.origin, targetURL.host) || textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)) { + return false; + } + + // The link hasn't been recognized, maybe it features an international domain name + const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC'); + const host = targetURL.host.replace(targetURL.hostname, hostname); + const origin = targetURL.origin.replace(targetURL.host, host); + const text = linkText.normalize('NFKC'); + return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host)); +}; export default class StatusContent extends React.PureComponent { @@ -18,6 +67,13 @@ export default class StatusContent extends React.PureComponent { parseClick: PropTypes.func, disabled: PropTypes.bool, onUpdate: PropTypes.func, + tagLinks: PropTypes.bool, + rewriteMentions: PropTypes.string, + }; + + static defaultProps = { + tagLinks: true, + rewriteMentions: 'no', }; state = { @@ -25,7 +81,8 @@ export default class StatusContent extends React.PureComponent { }; _updateStatusLinks () { - const node = this.node; + const node = this.contentsNode; + const { tagLinks, rewriteMentions } = this.props; if (!node) { return; @@ -45,24 +102,70 @@ export default class StatusContent extends React.PureComponent { if (mention) { link.addEventListener('click', this.onMentionClick.bind(this, mention), false); link.setAttribute('title', mention.get('acct')); + if (rewriteMentions !== 'no') { + while (link.firstChild) link.removeChild(link.firstChild); + link.appendChild(document.createTextNode('@')); + const acctSpan = document.createElement('span'); + acctSpan.textContent = rewriteMentions === 'acct' ? mention.get('acct') : mention.get('username'); + link.appendChild(acctSpan); + } } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); } else { link.addEventListener('click', this.onLinkClick.bind(this), false); link.setAttribute('title', link.href); + link.classList.add('unhandled-link'); + + try { + if (tagLinks && isLinkMisleading(link)) { + // Add a tag besides the link to display its origin + + const tag = document.createElement('span'); + tag.classList.add('link-origin-tag'); + tag.textContent = `[${new URL(link.href).host}]`; + link.insertAdjacentText('beforeend', ' '); + link.insertAdjacentElement('beforeend', tag); + } + } catch (e) { + // The URL is invalid, remove the href just to be safe + if (tagLinks && e instanceof TypeError) link.removeAttribute('href'); + } } link.setAttribute('target', '_blank'); - link.setAttribute('rel', 'noopener'); + link.setAttribute('rel', 'noopener noreferrer'); + } + } + + _updateStatusEmojis () { + const node = this.node; + + if (!node || autoPlayGif) { + return; + } + + const emojis = node.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + if (emoji.classList.contains('status-emoji')) { + continue; + } + emoji.classList.add('status-emoji'); + + emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false); + emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false); } } componentDidMount () { this._updateStatusLinks(); + this._updateStatusEmojis(); } componentDidUpdate () { this._updateStatusLinks(); + this._updateStatusEmojis(); if (this.props.onUpdate) this.props.onUpdate(); } @@ -79,13 +182,21 @@ export default class StatusContent extends React.PureComponent { } onHashtagClick = (hashtag, e) => { - hashtag = hashtag.replace(/^#/, '').toLowerCase(); + hashtag = hashtag.replace(/^#/, ''); if (this.props.parseClick) { this.props.parseClick(e, `/timelines/tag/${hashtag}`); } } + handleEmojiMouseEnter = ({ target }) => { + target.src = target.getAttribute('data-original'); + } + + handleEmojiMouseLeave = ({ target }) => { + target.src = target.getAttribute('data-static'); + } + handleMouseDown = (e) => { this.startXY = [e.clientX, e.clientY]; } @@ -102,7 +213,7 @@ export default class StatusContent extends React.PureComponent { let element = e.target; while (element) { - if (element.localName === 'button' || element.localName === 'video' || element.localName === 'a' || element.localName === 'label') { + if (['button', 'video', 'a', 'label', 'wave'].includes(element.localName)) { return; } element = element.parentNode; @@ -129,6 +240,10 @@ export default class StatusContent extends React.PureComponent { this.node = c; } + setContentsRef = (c) => { + this.contentsNode = c; + } + render () { const { status, @@ -136,6 +251,8 @@ export default class StatusContent extends React.PureComponent { mediaIcon, parseClick, disabled, + tagLinks, + rewriteMentions, } = this.props; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; @@ -173,10 +290,10 @@ export default class StatusContent extends React.PureComponent { key='0' />, mediaIcon ? ( - <i - className={ - `fa fa-fw fa-${mediaIcon} status__content__spoiler-icon` - } + <Icon + fixedWidth + className='status__content__spoiler-icon' + id={mediaIcon} aria-hidden='true' key='1' /> @@ -194,11 +311,11 @@ export default class StatusContent extends React.PureComponent { } return ( - <div className={classNames} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> + <div className={classNames} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} ref={this.setRef}> <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }} > - <span dangerouslySetInnerHTML={spoilerContent} lang={status.get('language')} /> + <span dangerouslySetInnerHTML={spoilerContent} /> {' '} <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}> {toggleText} @@ -209,12 +326,12 @@ export default class StatusContent extends React.PureComponent { <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}> <div - ref={this.setRef} + ref={this.setContentsRef} + key={`contents-${tagLinks}`} style={directionStyle} tabIndex={!hidden ? 0 : null} dangerouslySetInnerHTML={content} className='status__content__text' - lang={status.get('language')} /> {media} </div> @@ -229,11 +346,12 @@ export default class StatusContent extends React.PureComponent { onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} tabIndex='0' + ref={this.setRef} > <div - ref={this.setRef} + ref={this.setContentsRef} + key={`contents-${tagLinks}-${rewriteMentions}`} dangerouslySetInnerHTML={content} - lang={status.get('language')} className='status__content__text' tabIndex='0' /> @@ -246,8 +364,9 @@ export default class StatusContent extends React.PureComponent { className='status__content' style={directionStyle} tabIndex='0' + ref={this.setRef} > - <div ref={this.setRef} className='status__content__text' dangerouslySetInnerHTML={content} lang={status.get('language')} tabIndex='0' /> + <div ref={this.setContentsRef} key={`contents-${tagLinks}`} className='status__content__text' dangerouslySetInnerHTML={content} tabIndex='0' /> {media} </div> ); diff --git a/app/javascript/flavours/glitch/components/status_header.js b/app/javascript/flavours/glitch/components/status_header.js index 23cff286a..06296e124 100644 --- a/app/javascript/flavours/glitch/components/status_header.js +++ b/app/javascript/flavours/glitch/components/status_header.js @@ -56,6 +56,7 @@ export default class StatusHeader extends React.PureComponent { target='_blank' className='status__avatar' onClick={this.handleAccountClick} + rel='noopener noreferrer' > {statusAvatar} </a> @@ -64,6 +65,7 @@ export default class StatusHeader extends React.PureComponent { target='_blank' className='status__display-name' onClick={this.handleAccountClick} + rel='noopener noreferrer' > <DisplayName account={account} others={otherAccounts} /> </a> diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js index 4a2c62881..f4d0a7405 100644 --- a/app/javascript/flavours/glitch/components/status_icons.js +++ b/app/javascript/flavours/glitch/components/status_icons.js @@ -7,15 +7,23 @@ import { defineMessages, injectIntl } from 'react-intl'; // Mastodon imports. import IconButton from './icon_button'; import VisibilityIcon from './status_visibility_icon'; +import Icon from 'flavours/glitch/components/icon'; // Messages for use with internationalization stuff. const messages = defineMessages({ collapse: { id: 'status.collapse', defaultMessage: 'Collapse' }, uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' }, + inReplyTo: { id: 'status.in_reply_to', defaultMessage: 'This toot is a reply' }, + previewCard: { id: 'status.has_preview_card', defaultMessage: 'Features an attached preview card' }, + pictures: { id: 'status.has_pictures', defaultMessage: 'Features attached pictures' }, + poll: { id: 'status.is_poll', defaultMessage: 'This toot is a poll' }, + video: { id: 'status.has_video', defaultMessage: 'Features attached videos' }, + audio: { id: 'status.has_audio', defaultMessage: 'Features attached audio files' }, + localOnly: { id: 'status.local_only', defaultMessage: 'Only visible from your instance' }, }); -@injectIntl -export default class StatusIcons extends React.PureComponent { +export default @injectIntl +class StatusIcons extends React.PureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, @@ -36,6 +44,23 @@ export default class StatusIcons extends React.PureComponent { } } + mediaIconTitleText () { + const { intl, mediaIcon } = this.props; + + switch (mediaIcon) { + case 'link': + return intl.formatMessage(messages.previewCard); + case 'picture-o': + return intl.formatMessage(messages.pictures); + case 'tasks': + return intl.formatMessage(messages.poll); + case 'video-camera': + return intl.formatMessage(messages.video); + case 'music': + return intl.formatMessage(messages.audio); + } + } + // Rendering. render () { const { @@ -50,22 +75,35 @@ export default class StatusIcons extends React.PureComponent { return ( <div className='status__info__icons'> {status.get('in_reply_to_id', null) !== null ? ( - <i - className={`fa fa-fw fa-comment status__reply-icon`} + <Icon + className='status__reply-icon' + fixedWidth + id='comment' aria-hidden='true' + title={intl.formatMessage(messages.inReplyTo)} /> ) : null} + {status.get('local_only') && + <Icon + fixedWidth + id='home' + aria-hidden='true' + title={intl.formatMessage(messages.localOnly)} + />} {mediaIcon ? ( - <i - className={`fa fa-fw fa-${mediaIcon} status__media-icon`} + <Icon + fixedWidth + className='status__media-icon' + id={mediaIcon} aria-hidden='true' + title={this.mediaIconTitleText()} /> ) : null} {!directMessage && <VisibilityIcon visibility={status.get('visibility')} />} {collapsible ? ( <IconButton className='status__collapse-button' - animate flip + animate active={collapsed} title={ collapsed ? diff --git a/app/javascript/flavours/glitch/components/status_list.js b/app/javascript/flavours/glitch/components/status_list.js index c1f51b307..a399ff567 100644 --- a/app/javascript/flavours/glitch/components/status_list.js +++ b/app/javascript/flavours/glitch/components/status_list.js @@ -6,7 +6,7 @@ import StatusContainer from 'flavours/glitch/containers/status_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; import LoadGap from './load_gap'; import ScrollableList from './scrollable_list'; -import { FormattedMessage } from 'react-intl'; +import RegenerationIndicator from 'flavours/glitch/components/regeneration_indicator'; export default class StatusList extends ImmutablePureComponent { @@ -81,18 +81,7 @@ export default class StatusList extends ImmutablePureComponent { const { isLoading, isPartial } = other; if (isPartial) { - return ( - <div className='regeneration-indicator'> - <div> - <div className='regeneration-indicator__figure' /> - - <div className='regeneration-indicator__label'> - <FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading…' /> - <FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' /> - </div> - </div> - </div> - ); + return <RegenerationIndicator />; } let scrollableContent = (isLoading || statusIds.size > 0) ? ( diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js index 481e6644e..637c4f23a 100644 --- a/app/javascript/flavours/glitch/components/status_prepend.js +++ b/app/javascript/flavours/glitch/components/status_prepend.js @@ -3,6 +3,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; +import { me } from 'flavours/glitch/util/initial_state'; export default class StatusPrepend extends React.PureComponent { @@ -63,12 +65,21 @@ export default class StatusPrepend extends React.PureComponent { /> ); case 'poll': - return ( - <FormattedMessage - id='notification.poll' - defaultMessage='A poll you have voted in has ended' - /> - ); + if (me === account.get('id')) { + return ( + <FormattedMessage + id='notification.own_poll' + defaultMessage='Your poll has ended' + /> + ); + } else { + return ( + <FormattedMessage + id='notification.poll' + defaultMessage='A poll you have voted in has ended' + /> + ); + } } return null; } @@ -80,10 +91,9 @@ export default class StatusPrepend extends React.PureComponent { return !type ? null : ( <aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}> <div className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}> - <i - className={`fa fa-fw fa-${ - type === 'favourite' ? 'star star-icon' : (type === 'featured' ? 'thumb-tack' : (type === 'poll' ? 'tasks' : 'retweet')) - } status__prepend-icon`} + <Icon + className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`} + id={type === 'favourite' ? 'star' : (type === 'featured' ? 'thumb-tack' : (type === 'poll' ? 'tasks' : 'retweet'))} /> </div> <Message /> diff --git a/app/javascript/flavours/glitch/components/status_visibility_icon.js b/app/javascript/flavours/glitch/components/status_visibility_icon.js index 5e7b8ed00..e2e0f30b8 100644 --- a/app/javascript/flavours/glitch/components/status_visibility_icon.js +++ b/app/javascript/flavours/glitch/components/status_visibility_icon.js @@ -3,6 +3,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import Icon from 'flavours/glitch/components/icon'; const messages = defineMessages({ public: { id: 'privacy.public.short', defaultMessage: 'Public' }, @@ -11,8 +12,8 @@ const messages = defineMessages({ direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, }); -@injectIntl -export default class VisibilityIcon extends ImmutablePureComponent { +export default @injectIntl +class VisibilityIcon extends ImmutablePureComponent { static propTypes = { visibility: PropTypes.string, @@ -23,7 +24,7 @@ export default class VisibilityIcon extends ImmutablePureComponent { render() { const { withLabel, visibility, intl } = this.props; - const visibilityClass = { + const visibilityIcon = { public: 'globe', unlisted: 'unlock', private: 'lock', @@ -32,8 +33,10 @@ export default class VisibilityIcon extends ImmutablePureComponent { const label = intl.formatMessage(messages[visibility]); - const icon = (<i - className={`status__visibility-icon fa fa-fw fa-${visibilityClass}`} + const icon = (<Icon + className='status__visibility-icon' + fixedWidth + id={visibilityIcon} title={label} aria-hidden='true' />); diff --git a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js index b2419a0fd..1378e75fe 100644 --- a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js +++ b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js @@ -25,7 +25,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({ }) : openDropdownMenu(id, dropdownPlacement, keyboard)); }, onClose(id) { - dispatch(closeModal()); + dispatch(closeModal('ACTIONS')); dispatch(closeDropdownMenu(id)); }, }); diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js index 59eef6636..8101be87e 100644 --- a/app/javascript/flavours/glitch/containers/mastodon.js +++ b/app/javascript/flavours/glitch/containers/mastodon.js @@ -49,6 +49,10 @@ export default class Mastodon extends React.PureComponent { } } + shouldUpdateScroll (_, { location }) { + return !(location.state && location.state.mastodonModalOpen); + } + render () { const { locale } = this.props; @@ -57,7 +61,7 @@ export default class Mastodon extends React.PureComponent { <Provider store={store}> <ErrorBoundary> <BrowserRouter basename='/web'> - <ScrollContext> + <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}> <Route path='/' component={UI} /> </ScrollContext> </BrowserRouter> diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js index 1b480658f..a483510b0 100644 --- a/app/javascript/flavours/glitch/containers/media_container.js +++ b/app/javascript/flavours/glitch/containers/media_container.js @@ -2,19 +2,22 @@ import React, { PureComponent, Fragment } from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import { IntlProvider, addLocaleData } from 'react-intl'; +import { List as ImmutableList, fromJS } from 'immutable'; import { getLocale } from 'mastodon/locales'; +import { getScrollbarWidth } from 'flavours/glitch/util/scrollbar'; import MediaGallery from 'flavours/glitch/components/media_gallery'; -import Video from 'flavours/glitch/features/video'; -import Card from 'flavours/glitch/features/status/components/card'; import Poll from 'flavours/glitch/components/poll'; +import Hashtag from 'flavours/glitch/components/hashtag'; import ModalRoot from 'flavours/glitch/components/modal_root'; import MediaModal from 'flavours/glitch/features/ui/components/media_modal'; -import { List as ImmutableList, fromJS } from 'immutable'; +import Video from 'flavours/glitch/features/video'; +import Card from 'flavours/glitch/features/status/components/card'; +import Audio from 'flavours/glitch/features/audio'; const { localeData, messages } = getLocale(); addLocaleData(localeData); -const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll }; +const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio }; export default class MediaContainer extends PureComponent { @@ -31,6 +34,8 @@ export default class MediaContainer extends PureComponent { handleOpenMedia = (media, index) => { document.body.classList.add('with-modals--active'); + document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; + this.setState({ media, index }); } @@ -38,11 +43,15 @@ export default class MediaContainer extends PureComponent { const media = ImmutableList([video]); document.body.classList.add('with-modals--active'); + document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; + this.setState({ media, time }); } handleCloseMedia = () => { document.body.classList.remove('with-modals--active'); + document.documentElement.style.marginRight = 0; + this.setState({ media: null, index: null, time: null }); } @@ -55,12 +64,13 @@ export default class MediaContainer extends PureComponent { {[].map.call(components, (component, i) => { const componentName = component.getAttribute('data-component'); const Component = MEDIA_COMPONENTS[componentName]; - const { media, card, poll, ...props } = JSON.parse(component.getAttribute('data-props')); + const { media, card, poll, hashtag, ...props } = JSON.parse(component.getAttribute('data-props')); Object.assign(props, { - ...(media ? { media: fromJS(media) } : {}), - ...(card ? { card: fromJS(card) } : {}), - ...(poll ? { poll: fromJS(poll) } : {}), + ...(media ? { media: fromJS(media) } : {}), + ...(card ? { card: fromJS(card) } : {}), + ...(poll ? { poll: fromJS(poll) } : {}), + ...(hashtag ? { hashtag: fromJS(hashtag) } : {}), ...(componentName === 'Video' ? { onOpenVideo: this.handleOpenVideo, @@ -74,6 +84,7 @@ export default class MediaContainer extends PureComponent { component, ); })} + <ModalRoot onClose={this.handleCloseMedia}> {this.state.media && ( <MediaModal diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index a6069cb90..4c3555dea 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -1,7 +1,7 @@ -import React from 'react'; import { connect } from 'react-redux'; import Status from 'flavours/glitch/components/status'; -import { makeGetStatus } from 'flavours/glitch/selectors'; +import { List as ImmutableList } from 'immutable'; +import { makeGetStatus, regexFromFilters, toServerSideType } from 'flavours/glitch/selectors'; import { replyCompose, mentionCompose, @@ -17,25 +17,31 @@ import { pin, unpin, } from 'flavours/glitch/actions/interactions'; -import { blockAccount } from 'flavours/glitch/actions/accounts'; import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses'; import { initMuteModal } from 'flavours/glitch/actions/mutes'; +import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initReport } from 'flavours/glitch/actions/reports'; import { openModal } from 'flavours/glitch/actions/modal'; import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state'; +import { filterEditLink } from 'flavours/glitch/util/backend_links'; import { showAlertForError } from '../actions/alerts'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import Spoilers from '../components/spoilers'; +import Icon from 'flavours/glitch/components/icon'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' }, - blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, - blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, + unfilterConfirm: { id: 'confirmations.unfilter.confirm', defaultMessage: 'Show' }, + author: { id: 'confirmations.unfilter.author', defaultMessage: 'Author' }, + matchingFilters: { id: 'confirmations.unfilter.filters', defaultMessage: 'Matching {count, plural, one {filter} other {filters}}' }, + editFilter: { id: 'confirmations.unfilter.edit_filter', defaultMessage: 'Edit filter' }, }); const makeMapStateToProps = () => { @@ -69,11 +75,12 @@ const makeMapStateToProps = () => { return mapStateToProps; }; -const mapDispatchToProps = (dispatch, { intl }) => ({ +const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ onReply (status, router) { dispatch((_, getState) => { let state = getState(); + if (state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0) { dispatch(openModal('CONFIRM', { message: intl.formatMessage(messages.replyMessage), @@ -99,7 +106,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch((_, getState) => { let state = getState(); if (state.getIn(['local_settings', 'confirm_boost_missing_media_description']) && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) { - dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog, missingMediaDescription: true })); + dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog, missingMediaDescription: true })); } else if (e.shiftKey || !boostModal) { this.onModalReblog(status); } else { @@ -177,16 +184,49 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onBlock (status) { const account = status.get('account'); - dispatch(openModal('CONFIRM', { - message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, - confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.get('id'))), - secondary: intl.formatMessage(messages.blockAndReport), - onSecondary: () => { - dispatch(blockAccount(account.get('id'))); - dispatch(initReport(account, status)); - }, - })); + dispatch(initBlockModal(account)); + }, + + onUnfilter (status, onConfirm) { + dispatch((_, getState) => { + let state = getState(); + const serverSideType = toServerSideType(contextType); + const enabledFilters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))).toArray(); + const searchIndex = status.get('search_index'); + const matchingFilters = enabledFilters.filter(filter => regexFromFilters([filter]).test(searchIndex)); + dispatch(openModal('CONFIRM', { + message: [ + <FormattedMessage id='confirmations.unfilter' defaultMessage='Information about this filtered toot' />, + <div className='filtered-status-info'> + <Spoilers spoilerText={intl.formatMessage(messages.author)}> + <AccountContainer id={status.getIn(['account', 'id'])} /> + </Spoilers> + <Spoilers spoilerText={intl.formatMessage(messages.matchingFilters, {count: matchingFilters.size})}> + <ul> + {matchingFilters.map(filter => ( + <li> + {filter.get('phrase')} + {!!filterEditLink && ' '} + {!!filterEditLink && ( + <a + target='_blank' + className='filtered-status-edit-link' + title={intl.formatMessage(messages.editFilter)} + href={filterEditLink(filter.get('id'))} + > + <Icon icon='pencil' /> + </a> + )} + </li> + ))} + </ul> + </Spoilers> + </div> + ], + confirm: intl.formatMessage(messages.unfilterConfirm), + onConfirm: onConfirm, + })); + }); }, onReport (status) { diff --git a/app/javascript/flavours/glitch/features/account/components/action_bar.js b/app/javascript/flavours/glitch/features/account/components/action_bar.js index a2c00c1c2..6576bff8e 100644 --- a/app/javascript/flavours/glitch/features/account/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js @@ -8,8 +8,8 @@ import { me, isStaff } from 'flavours/glitch/util/initial_state'; import { profileLink, accountAdminLink } from 'flavours/glitch/util/backend_links'; import Icon from 'flavours/glitch/components/icon'; -@injectIntl -export default class ActionBar extends React.PureComponent { +export default @injectIntl +class ActionBar extends React.PureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, @@ -31,7 +31,7 @@ export default class ActionBar extends React.PureComponent { if (account.get('acct') !== account.get('username')) { extraInfo = ( <div className='account__disclaimer'> - <Icon icon='info-circle' fixedWidth /> <FormattedMessage + <Icon id='info-circle' fixedWidth /> <FormattedMessage id='account.disclaimer_full' defaultMessage="Information below may reflect the user's profile incompletely." /> diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js index 43c4f0d32..6b4aff616 100644 --- a/app/javascript/flavours/glitch/features/account/components/header.js +++ b/app/javascript/flavours/glitch/features/account/components/header.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { autoPlayGif, me, isStaff } from 'flavours/glitch/util/initial_state'; +import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/util/backend_links'; import classNames from 'classnames'; import Icon from 'flavours/glitch/components/icon'; import Avatar from 'flavours/glitch/components/avatar'; @@ -15,6 +16,7 @@ import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_cont const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, + cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Cancel follow request' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, @@ -68,7 +70,48 @@ class Header extends ImmutablePureComponent { }; openEditProfile = () => { - window.open('/settings/profile', '_blank'); + window.open(profileLink, '_blank'); + } + + _updateEmojis () { + const node = this.node; + + if (!node || autoPlayGif) { + return; + } + + const emojis = node.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + if (emoji.classList.contains('status-emoji')) { + continue; + } + emoji.classList.add('status-emoji'); + + emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false); + emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false); + } + } + + componentDidMount () { + this._updateEmojis(); + } + + componentDidUpdate () { + this._updateEmojis(); + } + + handleEmojiMouseEnter = ({ target }) => { + target.src = target.getAttribute('data-original'); + } + + handleEmojiMouseLeave = ({ target }) => { + target.src = target.getAttribute('data-static'); + } + + setRef = (c) => { + this.node = c; } render () { @@ -100,13 +143,13 @@ class Header extends ImmutablePureComponent { if (!account.get('relationship')) { // Wait until the relationship is loaded actionBtn = ''; } else if (account.getIn(['relationship', 'requested'])) { - actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />; + actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />; } else if (!account.getIn(['relationship', 'blocking'])) { actionBtn = <Button className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />; } else if (account.getIn(['relationship', 'blocking'])) { actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />; } - } else { + } else if (profileLink) { actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />; } @@ -115,7 +158,7 @@ class Header extends ImmutablePureComponent { } if (account.get('locked')) { - lockedIcon = <Icon icon='lock' title={intl.formatMessage(messages.account_locked)} />; + lockedIcon = <Icon id='lock' title={intl.formatMessage(messages.account_locked)} />; } if (account.get('id') !== me) { @@ -130,8 +173,8 @@ class Header extends ImmutablePureComponent { } if (account.get('id') === me) { - menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); - menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' }); + if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink }); + if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink }); menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' }); menu.push(null); menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); @@ -181,19 +224,28 @@ class Header extends ImmutablePureComponent { } } - if (account.get('id') !== me && isStaff) { + if (account.get('id') !== me && isStaff && accountAdminLink) { menu.push(null); - menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${account.get('id')}` }); + menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: accountAdminLink(account.get('id')) }); } const content = { __html: account.get('note_emojified') }; const displayNameHtml = { __html: account.get('display_name_html') }; const fields = account.get('fields'); - const badge = account.get('bot') ? (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>) : null; const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct'); + let badge; + + if (account.get('bot')) { + badge = (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>); + } else if (account.get('group')) { + badge = (<div className='account-role group'><FormattedMessage id='account.badges.group' defaultMessage='Group' /></div>); + } else { + badge = null; + } + return ( - <div className={classNames('account__header', { inactive: !!account.get('moved') })}> + <div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}> <div className='account__header__image'> <div className='account__header__info'> {info} @@ -204,7 +256,7 @@ class Header extends ImmutablePureComponent { <div className='account__header__bar'> <div className='account__header__tabs'> - <a className='avatar' href={account.get('url')} rel='noopener' target='_blank'> + <a className='avatar' href={account.get('url')} rel='noopener noreferrer' target='_blank'> <Avatar account={account} size={90} /> </a> @@ -233,10 +285,10 @@ class Header extends ImmutablePureComponent { <dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} /> <dd className='verified'> - <a href={proof.get('proof_url')} target='_blank' rel='noopener'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}> + <a href={proof.get('proof_url')} target='_blank' rel='noopener noreferrer'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}> <Icon id='check' className='verified__mark' /> </span></a> - <a href={proof.get('profile_url')} target='_blank' rel='noopener'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} /></a> + <a href={proof.get('profile_url')} target='_blank' rel='noopener noreferrer'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} /></a> </dd> </dl> ))} diff --git a/app/javascript/flavours/glitch/features/account/components/profile_column_header.js b/app/javascript/flavours/glitch/features/account/components/profile_column_header.js index b6d373a2c..17c08e375 100644 --- a/app/javascript/flavours/glitch/features/account/components/profile_column_header.js +++ b/app/javascript/flavours/glitch/features/account/components/profile_column_header.js @@ -12,11 +12,12 @@ class ProfileColumnHeader extends React.PureComponent { static propTypes = { onClick: PropTypes.func, + multiColumn: PropTypes.bool, intl: PropTypes.object.isRequired, }; render() { - const { onClick, intl } = this.props; + const { onClick, intl, multiColumn } = this.props; return ( <ColumnHeader @@ -24,6 +25,7 @@ class ProfileColumnHeader extends React.PureComponent { title={intl.formatMessage(messages.profile)} onClick={onClick} showBackButton + multiColumn={multiColumn} /> ); } diff --git a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js index 026136b2c..f1cb3f9e4 100644 --- a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js +++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js @@ -1,11 +1,12 @@ -import React from 'react'; +import { decode } from 'blurhash'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; +import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state'; +import { isIOS } from 'flavours/glitch/util/is_mobile'; import PropTypes from 'prop-types'; +import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state'; -import classNames from 'classnames'; -import { decode } from 'blurhash'; -import { isIOS } from 'flavours/glitch/util/is_mobile'; export default class MediaItem extends ImmutablePureComponent { @@ -94,6 +95,12 @@ export default class MediaItem extends ImmutablePureComponent { if (attachment.get('type') === 'unknown') { // Skip + } else if (attachment.get('type') === 'audio') { + thumbnail = ( + <span className='account-gallery__item__icons'> + <Icon id='music' /> + </span> + ); } else if (attachment.get('type') === 'image') { const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; @@ -111,6 +118,7 @@ export default class MediaItem extends ImmutablePureComponent { ); } else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) { const autoPlay = !isIOS() && autoPlayGif; + const label = attachment.get('type') === 'video' ? <Icon id='play' /> : 'GIF'; thumbnail = ( <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> @@ -126,20 +134,21 @@ export default class MediaItem extends ImmutablePureComponent { loop muted /> - <span className='media-gallery__gifv__label'>GIF</span> + + <span className='media-gallery__gifv__label'>{label}</span> </div> ); } const icon = ( <span className='account-gallery__item__icons'> - <i className='fa fa-eye-slash' /> + <Icon id='eye-slash' /> </span> ); return ( <div className='account-gallery__item' style={{ width, height }}> - <a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick} title={title}> + <a className='media-gallery__item-thumbnail' href={status.get('url')} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'> <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} /> {visible ? thumbnail : icon} </a> diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js index 3e4421306..f5fe6c930 100644 --- a/app/javascript/flavours/glitch/features/account_gallery/index.js +++ b/app/javascript/flavours/glitch/features/account_gallery/index.js @@ -45,8 +45,8 @@ class LoadMoreMedia extends ImmutablePureComponent { } -@connect(mapStateToProps) -export default class AccountGallery extends ImmutablePureComponent { +export default @connect(mapStateToProps) +class AccountGallery extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, @@ -55,6 +55,7 @@ export default class AccountGallery extends ImmutablePureComponent { isLoading: PropTypes.bool, hasMore: PropTypes.bool, isAccount: PropTypes.bool, + multiColumn: PropTypes.bool, }; state = { @@ -113,6 +114,8 @@ export default class AccountGallery extends ImmutablePureComponent { handleOpenMedia = attachment => { if (attachment.get('type') === 'video') { this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') })); + } else if (attachment.get('type') === 'audio') { + this.props.dispatch(openModal('AUDIO', { media: attachment, status: attachment.get('status') })); } else { const media = attachment.getIn(['status', 'media_attachments']); const index = media.findIndex(x => x.get('id') === attachment.get('id')); @@ -128,7 +131,7 @@ export default class AccountGallery extends ImmutablePureComponent { } render () { - const { attachments, isLoading, hasMore, isAccount } = this.props; + const { attachments, isLoading, hasMore, isAccount, multiColumn } = this.props; const { width } = this.state; if (!isAccount) { @@ -155,7 +158,7 @@ export default class AccountGallery extends ImmutablePureComponent { return ( <Column ref={this.setColumnRef}> - <ProfileColumnHeader onClick={this.handleHeaderClick} /> + <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}> <div className='scrollable scrollable--flex' onScroll={this.handleScroll}> diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js index 1fab083db..fcaa7b494 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js +++ b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js @@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import AvatarOverlay from '../../../components/avatar_overlay'; import DisplayName from '../../../components/display_name'; +import Icon from 'flavours/glitch/components/icon'; export default class MovedNote extends ImmutablePureComponent { @@ -35,7 +36,7 @@ export default class MovedNote extends ImmutablePureComponent { return ( <div className='account__moved-note'> <div className='account__moved-note__message'> - <div className='account__moved-note__icon-wrapper'><i className='fa fa-fw fa-suitcase account__moved-note__icon' /></div> + <div className='account__moved-note__icon-wrapper'><Icon id='suitcase' className='account__moved-note__icon' fixedWidth /></div> <FormattedMessage id='account.moved_to' defaultMessage='{name} has moved to:' values={{ name: <bdi><strong dangerouslySetInnerHTML={displayNameHtml} /></bdi> }} /> </div> diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js index 787a36658..fff5e097f 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js +++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js @@ -5,7 +5,6 @@ import Header from '../components/header'; import { followAccount, unfollowAccount, - blockAccount, unblockAccount, unmuteAccount, pinAccount, @@ -16,6 +15,7 @@ import { directCompose } from 'flavours/glitch/actions/compose'; import { initMuteModal } from 'flavours/glitch/actions/mutes'; +import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initReport } from 'flavours/glitch/actions/reports'; import { openModal } from 'flavours/glitch/actions/modal'; import { blockDomain, unblockDomain } from 'flavours/glitch/actions/domain_blocks'; @@ -25,9 +25,7 @@ import { List as ImmutableList } from 'immutable'; const messages = defineMessages({ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, - blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, - blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, }); const makeMapStateToProps = () => { @@ -64,16 +62,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ if (account.getIn(['relationship', 'blocking'])) { dispatch(unblockAccount(account.get('id'))); } else { - dispatch(openModal('CONFIRM', { - message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, - confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.get('id'))), - secondary: intl.formatMessage(messages.blockAndReport), - onSecondary: () => { - dispatch(blockAccount(account.get('id'))); - dispatch(initReport(account)); - }, - })); + dispatch(initBlockModal(account)); } }, diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js index 93d8fc9ec..2ef4ff602 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.js +++ b/app/javascript/flavours/glitch/features/account_timeline/index.js @@ -9,6 +9,7 @@ import LoadingIndicator from '../../components/loading_indicator'; import Column from '../ui/components/column'; import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; import HeaderContainer from './containers/header_container'; +import ColumnBackButton from 'flavours/glitch/components/column_back_button'; import { List as ImmutableList } from 'immutable'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { FormattedMessage } from 'react-intl'; @@ -27,8 +28,8 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false }) }; }; -@connect(mapStateToProps) -export default class AccountTimeline extends ImmutablePureComponent { +export default @connect(mapStateToProps) +class AccountTimeline extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, @@ -39,6 +40,7 @@ export default class AccountTimeline extends ImmutablePureComponent { hasMore: PropTypes.bool, withReplies: PropTypes.bool, isAccount: PropTypes.bool, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -76,11 +78,12 @@ export default class AccountTimeline extends ImmutablePureComponent { } render () { - const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount } = this.props; + const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, multiColumn } = this.props; if (!isAccount) { return ( <Column> + <ColumnBackButton multiColumn={multiColumn} /> <MissingIndicator /> </Column> ); @@ -96,7 +99,7 @@ export default class AccountTimeline extends ImmutablePureComponent { return ( <Column ref={this.setRef} name='account'> - <ProfileColumnHeader onClick={this.handleHeaderClick} /> + <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> <StatusList prepend={<HeaderContainer accountId={this.props.params.accountId} />} @@ -108,6 +111,7 @@ export default class AccountTimeline extends ImmutablePureComponent { hasMore={hasMore} onLoadMore={this.handleLoadMore} emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js new file mode 100644 index 000000000..033d92adf --- /dev/null +++ b/app/javascript/flavours/glitch/features/audio/index.js @@ -0,0 +1,236 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import WaveSurfer from 'wavesurfer.js'; +import { defineMessages, injectIntl } from 'react-intl'; +import { formatTime } from 'flavours/glitch/features/video'; +import Icon from 'flavours/glitch/components/icon'; +import classNames from 'classnames'; +import { throttle } from 'lodash'; + +const messages = defineMessages({ + play: { id: 'video.play', defaultMessage: 'Play' }, + pause: { id: 'video.pause', defaultMessage: 'Pause' }, + mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, + unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, + download: { id: 'video.download', defaultMessage: 'Download file' }, +}); + +export default @injectIntl +class Audio extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + duration: PropTypes.number, + peaks: PropTypes.arrayOf(PropTypes.number), + height: PropTypes.number, + preload: PropTypes.bool, + editable: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + state = { + currentTime: 0, + duration: null, + paused: true, + muted: false, + volume: 0.5, + }; + + // hard coded in components.scss + // any way to get ::before values programatically? + + volWidth = 50; + + volOffset = 70; + + volHandleOffset = v => { + const offset = v * this.volWidth + this.volOffset; + return (offset > 110) ? 110 : offset; + } + + setVolumeRef = c => { + this.volume = c; + } + + setWaveformRef = c => { + this.waveform = c; + } + + componentDidMount () { + if (this.waveform) { + this._updateWaveform(); + } + } + + componentDidUpdate (prevProps) { + if (this.waveform && prevProps.src !== this.props.src) { + this._updateWaveform(); + } + } + + componentWillUnmount () { + if (this.wavesurfer) { + this.wavesurfer.destroy(); + this.wavesurfer = null; + } + } + + _updateWaveform () { + const { src, height, duration, peaks, preload } = this.props; + + const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color'); + const waveColor = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color'); + + if (this.wavesurfer) { + this.wavesurfer.destroy(); + this.loaded = false; + } + + const wavesurfer = WaveSurfer.create({ + container: this.waveform, + height, + barWidth: 3, + cursorWidth: 0, + progressColor, + waveColor, + backend: 'MediaElement', + interact: preload, + }); + + wavesurfer.setVolume(this.state.volume); + + if (preload) { + wavesurfer.load(src); + this.loaded = true; + } else { + wavesurfer.load(src, peaks, 'none', duration); + this.loaded = false; + } + + wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) })); + wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) })); + wavesurfer.on('pause', () => this.setState({ paused: true })); + wavesurfer.on('play', () => this.setState({ paused: false })); + wavesurfer.on('volume', volume => this.setState({ volume })); + wavesurfer.on('mute', muted => this.setState({ muted })); + + this.wavesurfer = wavesurfer; + } + + togglePlay = () => { + if (this.state.paused) { + if (!this.props.preload && !this.loaded) { + this.wavesurfer.createBackend(); + this.wavesurfer.createPeakCache(); + this.wavesurfer.load(this.props.src); + this.wavesurfer.toggleInteraction(); + this.loaded = true; + } + + this.wavesurfer.play(); + this.setState({ paused: false }); + } else { + this.wavesurfer.pause(); + this.setState({ paused: true }); + } + } + + toggleMute = () => { + this.wavesurfer.setMute(!this.state.muted); + } + + handleVolumeMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseVolSlide, true); + document.addEventListener('mouseup', this.handleVolumeMouseUp, true); + document.addEventListener('touchmove', this.handleMouseVolSlide, true); + document.addEventListener('touchend', this.handleVolumeMouseUp, true); + + this.handleMouseVolSlide(e); + + e.preventDefault(); + e.stopPropagation(); + } + + handleVolumeMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseVolSlide, true); + document.removeEventListener('mouseup', this.handleVolumeMouseUp, true); + document.removeEventListener('touchmove', this.handleMouseVolSlide, true); + document.removeEventListener('touchend', this.handleVolumeMouseUp, true); + } + + handleMouseVolSlide = throttle(e => { + const rect = this.volume.getBoundingClientRect(); + const x = (e.clientX - rect.left) / this.volWidth; // x position within the element. + + if(!isNaN(x)) { + let slideamt = x; + + if (x > 1) { + slideamt = 1; + } else if(x < 0) { + slideamt = 0; + } + + this.wavesurfer.setVolume(slideamt); + } + }, 60); + + render () { + const { height, intl, alt, editable } = this.props; + const { paused, muted, volume, currentTime } = this.state; + + const volumeWidth = muted ? 0 : volume * this.volWidth; + const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume); + + return ( + <div className={classNames('audio-player', { editable })}> + <div className='audio-player__progress-placeholder' style={{ display: 'none' }} /> + <div className='audio-player__wave-placeholder' style={{ display: 'none' }} /> + + <div + className='audio-player__waveform' + aria-label={alt} + title={alt} + style={{ height }} + ref={this.setWaveformRef} + /> + + <div className='video-player__controls active'> + <div className='video-player__buttons-bar'> + <div className='video-player__buttons left'> + <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> + <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> + + <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}> + + <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} /> + + <span + className={classNames('video-player__volume__handle')} + tabIndex='0' + style={{ left: `${volumeHandleLoc}px` }} + /> + </div> + + <span> + <span className='video-player__time-current'>{formatTime(currentTime)}</span> + <span className='video-player__time-sep'>/</span> + <span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span> + </span> + </div> + + <div className='video-player__buttons right'> + <button type='button' aria-label={intl.formatMessage(messages.download)}> + <a className='video-player__download__icon' href={this.props.src} download> + <Icon id={'download'} fixedWidth /> + </a> + </button> + </div> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/blocks/index.js b/app/javascript/flavours/glitch/features/blocks/index.js index 386a0ce63..9eb6fe02e 100644 --- a/app/javascript/flavours/glitch/features/blocks/index.js +++ b/app/javascript/flavours/glitch/features/blocks/index.js @@ -1,14 +1,15 @@ import React from 'react'; import { connect } from 'react-redux'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import PropTypes from 'prop-types'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; -import { ScrollContainer } from 'react-router-scroll-4'; +import ScrollableList from '../../components/scrollable_list'; import Column from 'flavours/glitch/features/ui/components/column'; import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; import AccountContainer from 'flavours/glitch/containers/account_container'; import { fetchBlocks, expandBlocks } from 'flavours/glitch/actions/blocks'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; const messages = defineMessages({ @@ -17,38 +18,32 @@ const messages = defineMessages({ const mapStateToProps = state => ({ accountIds: state.getIn(['user_lists', 'blocks', 'items']), + hasMore: !!state.getIn(['user_lists', 'blocks', 'next']), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class Blocks extends ImmutablePureComponent { +class Blocks extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, }; componentWillMount () { this.props.dispatch(fetchBlocks()); } - handleScroll = (e) => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - - if (scrollTop === scrollHeight - clientHeight) { - this.props.dispatch(expandBlocks()); - } - } - - shouldUpdateScroll = (prevRouterProps, { location }) => { - if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; - return !(location.state && location.state.mastodonModalOpen); - } + handleLoadMore = debounce(() => { + this.props.dispatch(expandBlocks()); + }, 300, { leading: true }); render () { - const { intl, accountIds } = this.props; + const { intl, accountIds, hasMore, multiColumn } = this.props; if (!accountIds) { return ( @@ -58,16 +53,22 @@ export default class Blocks extends ImmutablePureComponent { ); } + const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />; + return ( - <Column name='blocks' icon='ban' heading={intl.formatMessage(messages.heading)}> + <Column name='blocks' bindToDocument={!multiColumn} icon='ban' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> - <ScrollContainer scrollKey='blocks' shouldUpdateScroll={this.shouldUpdateScroll}> - <div className='scrollable' onScroll={this.handleScroll}> - {accountIds.map(id => - <AccountContainer key={id} id={id} /> - )} - </div> - </ScrollContainer> + <ScrollableList + scrollKey='blocks' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} /> + )} + </ScrollableList> </Column> ); } diff --git a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js index 9468ad81d..58b9e6396 100644 --- a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js +++ b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js @@ -2,14 +2,14 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'flavours/glitch/actions/bookmarks'; import Column from 'flavours/glitch/features/ui/components/column'; import ColumnHeader from 'flavours/glitch/components/column_header'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import StatusList from 'flavours/glitch/components/status_list'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { debounce } from 'lodash'; const messages = defineMessages({ heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, @@ -21,9 +21,9 @@ const mapStateToProps = state => ({ hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class Bookmarks extends ImmutablePureComponent { +class Bookmarks extends ImmutablePureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, @@ -70,8 +70,10 @@ export default class Bookmarks extends ImmutablePureComponent { const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; const pinned = !!columnId; + const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked toots yet. When you bookmark one, it will show up here." />; + return ( - <Column ref={this.setRef} name='bookmarks'> + <Column bindToDocument={!multiColumn} ref={this.setRef} name='bookmarks'> <ColumnHeader icon='bookmark' title={intl.formatMessage(messages.heading)} @@ -90,6 +92,8 @@ export default class Bookmarks extends ImmutablePureComponent { hasMore={hasMore} isLoading={isLoading} onLoadMore={this.handleLoadMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js index 96db003ce..69a4699ac 100644 --- a/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js +++ b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js @@ -10,8 +10,8 @@ const messages = defineMessages({ settings: { id: 'home.settings', defaultMessage: 'Column settings' }, }); -@injectIntl -export default class ColumnSettings extends React.PureComponent { +export default @injectIntl +class ColumnSettings extends React.PureComponent { static propTypes = { settings: ImmutablePropTypes.map.isRequired, @@ -26,7 +26,7 @@ export default class ColumnSettings extends React.PureComponent { return ( <div> <div className='column-settings__row'> - <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} /> + <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} /> </div> <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> diff --git a/app/javascript/flavours/glitch/features/community_timeline/index.js b/app/javascript/flavours/glitch/features/community_timeline/index.js index 2c0fbff36..7341f9702 100644 --- a/app/javascript/flavours/glitch/features/community_timeline/index.js +++ b/app/javascript/flavours/glitch/features/community_timeline/index.js @@ -14,20 +14,22 @@ const messages = defineMessages({ title: { id: 'column.community', defaultMessage: 'Local timeline' }, }); -const mapStateToProps = (state, { onlyMedia, columnId }) => { +const mapStateToProps = (state, { columnId }) => { const uuid = columnId; const columns = state.getIn(['settings', 'columns']); const index = columns.findIndex(c => c.get('uuid') === uuid); + const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']); + const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]); return { - hasUnread: state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`, 'unread']) > 0, - onlyMedia: (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']), + hasUnread: !!timelineState && timelineState.get('unread') > 0, + onlyMedia, }; }; -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class CommunityTimeline extends React.PureComponent { +class CommunityTimeline extends React.PureComponent { static defaultProps = { onlyMedia: false, @@ -99,16 +101,12 @@ export default class CommunityTimeline extends React.PureComponent { dispatch(expandCommunityTimeline({ maxId, onlyMedia })); } - shouldUpdateScroll = (prevRouterProps, { location }) => { - return !(location.state && location.state.mastodonModalOpen) - } - render () { const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props; const pinned = !!columnId; return ( - <Column ref={this.setRef} name='local' label={intl.formatMessage(messages.title)}> + <Column ref={this.setRef} name='local' bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}> <ColumnHeader icon='users' active={hasUnread} @@ -125,10 +123,10 @@ export default class CommunityTimeline extends React.PureComponent { <StatusListContainer trackScroll={!pinned} scrollKey={`community_timeline-${columnId}`} - shouldUpdateScroll={this.shouldUpdateScroll} timelineId={`community${onlyMedia ? ':media' : ''}`} onLoadMore={this.handleLoadMore} emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/flavours/glitch/features/compose/components/character_counter.js b/app/javascript/flavours/glitch/features/compose/components/character_counter.js new file mode 100644 index 000000000..0ecfc9141 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/character_counter.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { length } from 'stringz'; + +export default class CharacterCounter extends React.PureComponent { + + static propTypes = { + text: PropTypes.string.isRequired, + max: PropTypes.number.isRequired, + }; + + checkRemainingText (diff) { + if (diff < 0) { + return <span className='character-counter character-counter--over'>{diff}</span>; + } + + return <span className='character-counter'>{diff}</span>; + } + + render () { + const diff = this.props.max - length(this.props.text); + return this.checkRemainingText(diff); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js index cbce675d5..6e07998ec 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -15,6 +15,8 @@ import { countableText } from 'flavours/glitch/util/counter'; import OptionsContainer from '../containers/options_container'; import Publisher from './publisher'; import TextareaIcons from './textarea_icons'; +import { maxChars } from 'flavours/glitch/util/initial_state'; +import CharacterCounter from './character_counter'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, @@ -119,14 +121,8 @@ class ComposeForm extends ImmutablePureComponent { // Submit unless there are media with missing descriptions if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) { - const firstWithoutDescription = media.findIndex(item => !item.get('description')); - if (uploadForm) { - const inputs = uploadForm.querySelectorAll('.composer--upload_form--item input'); - if (inputs.length == media.size && firstWithoutDescription !== -1) { - inputs[firstWithoutDescription].focus(); - } - } - onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null); + const firstWithoutDescription = media.find(item => !item.get('description')); + onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null, firstWithoutDescription.get('id')); } else if (onSubmit) { onSubmit(this.context.router ? this.context.router.history : null); } @@ -197,7 +193,10 @@ class ComposeForm extends ImmutablePureComponent { handleFocus = () => { if (this.composeForm && !this.props.singleColumn) { - this.composeForm.scrollIntoView(); + const { left, right } = this.composeForm.getBoundingClientRect(); + if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) { + this.composeForm.scrollIntoView(); + } } } @@ -295,13 +294,15 @@ class ComposeForm extends ImmutablePureComponent { let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia); + const countText = `${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`; + return ( - <div className='composer' ref={this.setRef}> + <div className='composer'> <WarningContainer /> <ReplyIndicatorContainer /> - <div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`}> + <div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`} ref={this.setRef}> <AutosuggestInput placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={spoilerText} @@ -344,19 +345,24 @@ class ComposeForm extends ImmutablePureComponent { </div> </AutosuggestTextarea> - <OptionsContainer - advancedOptions={advancedOptions} - disabled={isSubmitting} - onChangeVisibility={onChangeVisibility} - onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness} - onUpload={onPaste} - privacy={privacy} - sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)} - spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler} - /> + <div className='composer--options-wrapper'> + <OptionsContainer + advancedOptions={advancedOptions} + disabled={isSubmitting} + onChangeVisibility={onChangeVisibility} + onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness} + onUpload={onPaste} + privacy={privacy} + sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)} + spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler} + /> + <div className='compose--counter-wrapper'> + <CharacterCounter text={countText} max={maxChars} /> + </div> + </div> <Publisher - countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`} + countText={countText} disabled={disabledButton} onSecondarySubmit={handleSecondarySubmit} onSubmit={handleSubmit} diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js index 8d982208f..60035b705 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -12,33 +12,101 @@ import DropdownMenu from './dropdown_menu'; import { isUserTouching } from 'flavours/glitch/util/is_mobile'; import { assignHandlers } from 'flavours/glitch/util/react_helpers'; -// Handlers. -const handlers = { +// The component. +export default class ComposerOptionsDropdown extends React.PureComponent { - // Closes the dropdown. - handleClose () { - this.setState({ open: false }); - }, + static propTypes = { + active: PropTypes.bool, + disabled: PropTypes.bool, + icon: PropTypes.string, + items: PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + name: PropTypes.string.isRequired, + on: PropTypes.bool, + text: PropTypes.node, + })).isRequired, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + title: PropTypes.string, + value: PropTypes.string, + onChange: PropTypes.func, + }; + + state = { + needsModalUpdate: false, + open: false, + openedViaKeyboard: undefined, + placement: 'bottom', + }; - // The enter key toggles the dropdown's open state, and the escape - // key closes it. - handleKeyDown ({ key }) { - const { - handleClose, - handleToggle, - } = this.handlers; - switch (key) { + // Toggles opening and closing the dropdown. + handleToggle = ({ target, type }) => { + const { onModalOpen } = this.props; + const { open } = this.state; + + if (isUserTouching()) { + if (this.state.open) { + this.props.onModalClose(); + } else { + const modal = this.handleMakeModal(); + if (modal && onModalOpen) { + onModalOpen(modal); + } + } + } else { + const { top } = target.getBoundingClientRect(); + if (this.state.open && this.activeElement) { + this.activeElement.focus(); + } + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' }); + } + } + + handleKeyDown = (e) => { + switch (e.key) { + case 'Escape': + this.handleClose(); + break; + } + } + + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': case 'Enter': - handleToggle(key); + this.handleMouseDown(); break; - case 'Escape': - handleClose(); + } + } + + handleKeyPress = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleToggle(e); + e.stopPropagation(); + e.preventDefault(); break; } - }, + } + + handleClose = () => { + if (this.state.open && this.activeElement) { + this.activeElement.focus(); + } + this.setState({ open: false }); + } // Creates an action modal object. - handleMakeModal () { + handleMakeModal = () => { const component = this; const { items, @@ -76,74 +144,31 @@ const handlers = { }) ), }; - }, - - // Toggles opening and closing the dropdown. - handleToggle ({ target }) { - const { handleMakeModal } = this.handlers; - const { onModalOpen } = this.props; - const { open } = this.state; - - // If this is a touch device, we open a modal instead of the - // dropdown. - if (isUserTouching()) { - - // This gets the modal to open. - const modal = handleMakeModal(); - - // If we can, we then open the modal. - if (modal && onModalOpen) { - onModalOpen(modal); - return; - } - } - - const { top } = target.getBoundingClientRect(); - this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); - // Otherwise, we just set our state to open. - this.setState({ open: !open }); - }, + } // If our modal is open and our props update, we need to also update // the modal. - handleUpdate () { - const { handleMakeModal } = this.handlers; + handleUpdate = () => { const { onModalOpen } = this.props; const { needsModalUpdate } = this.state; // Gets our modal object. - const modal = handleMakeModal(); + const modal = this.handleMakeModal(); // Reopens the modal with the new object. if (needsModalUpdate && modal && onModalOpen) { onModalOpen(modal); } - }, -}; - -// The component. -export default class ComposerOptionsDropdown extends React.PureComponent { - - // Constructor. - constructor (props) { - super(props); - assignHandlers(this, handlers); - this.state = { - needsModalUpdate: false, - open: false, - placement: 'bottom', - }; } // Updates our modal as necessary. componentDidUpdate (prevProps) { - const { handleUpdate } = this.handlers; const { items } = this.props; const { needsModalUpdate } = this.state; if (needsModalUpdate && items.find( (item, i) => item.on !== prevProps.items[i].on )) { - handleUpdate(); + this.handleUpdate(); this.setState({ needsModalUpdate: false }); } } @@ -151,11 +176,6 @@ export default class ComposerOptionsDropdown extends React.PureComponent { // Rendering. render () { const { - handleClose, - handleKeyDown, - handleToggle, - } = this.handlers; - const { active, disabled, title, @@ -175,14 +195,18 @@ export default class ComposerOptionsDropdown extends React.PureComponent { return ( <div className={computedClass} - onKeyDown={handleKeyDown} + onKeyDown={this.handleKeyDown} > <IconButton active={open || active} className='value' disabled={disabled} icon={icon} - onClick={handleToggle} + inverted + onClick={this.handleToggle} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} + onKeyPress={this.handleKeyPress} size={18} style={{ height: null, @@ -199,8 +223,9 @@ export default class ComposerOptionsDropdown extends React.PureComponent { <DropdownMenu items={items} onChange={onChange} - onClose={handleClose} + onClose={this.handleClose} value={value} + openedViaKeyboard={this.state.openedViaKeyboard} /> </Overlay> </div> @@ -208,22 +233,3 @@ export default class ComposerOptionsDropdown extends React.PureComponent { } } - -// Props. -ComposerOptionsDropdown.propTypes = { - active: PropTypes.bool, - disabled: PropTypes.bool, - icon: PropTypes.string, - items: PropTypes.arrayOf(PropTypes.shape({ - icon: PropTypes.string, - meta: PropTypes.node, - name: PropTypes.string.isRequired, - on: PropTypes.bool, - text: PropTypes.node, - })).isRequired, - onChange: PropTypes.func, - onModalClose: PropTypes.func, - onModalOpen: PropTypes.func, - title: PropTypes.string, - value: PropTypes.string, -}; diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js index 19d35a8f4..404504e84 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js @@ -14,91 +14,6 @@ import { withPassive } from 'flavours/glitch/util/dom_helpers'; import Motion from 'flavours/glitch/util/optional_motion'; import { assignHandlers } from 'flavours/glitch/util/react_helpers'; -class ComposerOptionsDropdownContentItem extends ImmutablePureComponent { - - static propTypes = { - active: PropTypes.bool, - name: PropTypes.string, - onChange: PropTypes.func, - onClose: PropTypes.func, - options: PropTypes.shape({ - icon: PropTypes.string, - meta: PropTypes.node, - on: PropTypes.bool, - text: PropTypes.node, - }), - }; - - handleActivate = (e) => { - const { - name, - onChange, - onClose, - options: { on }, - } = this.props; - - // If the escape key was pressed, we close the dropdown. - if (e.key === 'Escape' && onClose) { - onClose(); - - // Otherwise, we both close the dropdown and change the value. - } else if (onChange && (!e.key || e.key === 'Enter')) { - e.preventDefault(); // Prevents change in focus on click - if ((on === null || typeof on === 'undefined') && onClose) { - onClose(); - } - onChange(name); - } - } - - // Rendering. - render () { - const { - active, - options: { - icon, - meta, - on, - text, - }, - } = this.props; - const computedClass = classNames('composer--options--dropdown--content--item', { - active, - lengthy: meta, - 'toggled-off': !on && on !== null && typeof on !== 'undefined', - 'toggled-on': on, - 'with-icon': icon, - }); - - let prefix = null; - - if (on !== null && typeof on !== 'undefined') { - prefix = <Toggle checked={on} onChange={this.handleActivate} />; - } else if (icon) { - prefix = <Icon className='icon' fullwidth icon={icon} /> - } - - // The result. - return ( - <div - className={computedClass} - onClick={this.handleActivate} - onKeyDown={this.handleActivate} - role='button' - tabIndex='0' - > - {prefix} - - <div className='content'> - <strong>{text}</strong> - {meta} - </div> - </div> - ); - } - -}; - // The spring to use with our motion. const springMotion = spring(1, { damping: 35, @@ -116,10 +31,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent on: PropTypes.bool, text: PropTypes.node, })), - onChange: PropTypes.func, - onClose: PropTypes.func, + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, style: PropTypes.object, value: PropTypes.string, + openedViaKeyboard: PropTypes.bool, }; static defaultProps = { @@ -128,14 +44,13 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent state = { mounted: false, + value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined, }; // When the document is clicked elsewhere, we close the dropdown. - handleDocumentClick = ({ target }) => { - const { node } = this; - const { onClose } = this.props; - if (onClose && node && !node.contains(target)) { - onClose(); + handleDocumentClick = (e) => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); } } @@ -148,6 +63,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent componentDidMount () { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('touchend', this.handleDocumentClick, withPassive); + if (this.focusedItem) { + this.focusedItem.focus(); + } else { + this.node.firstChild.focus(); + } this.setState({ mounted: true }); } @@ -157,6 +77,135 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent document.removeEventListener('touchend', this.handleDocumentClick, withPassive); } + handleClick = (name, e) => { + const { + onChange, + onClose, + items, + } = this.props; + + const { on } = this.props.items.find(item => item.name === name); + e.preventDefault(); // Prevents change in focus on click + if ((on === null || typeof on === 'undefined')) { + onClose(); + } + onChange(name); + } + + // Handle changes differently whether the dropdown is a list of options or actions + handleChange = (name) => { + if (this.props.value) { + this.props.onChange(name); + } else { + this.setState({ value: name }); + } + } + + handleKeyDown = (name, e) => { + const { items } = this.props; + const index = items.findIndex(item => { + return (item.name === name); + }); + let element; + + switch(e.key) { + case 'Escape': + this.props.onClose(); + break; + case 'Enter': + case ' ': + this.handleClick(e); + break; + case 'ArrowDown': + element = this.node.childNodes[index + 1]; + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + } + break; + case 'ArrowUp': + element = this.node.childNodes[index - 1]; + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + } + break; + case 'Tab': + if (e.shiftKey) { + element = this.node.childNodes[index - 1] || this.node.lastChild; + } else { + element = this.node.childNodes[index + 1] || this.node.firstChild; + } + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + e.preventDefault(); + e.stopPropagation(); + } + break; + case 'Home': + element = this.node.firstChild; + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + } + break; + case 'End': + element = this.node.lastChild; + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + } + break; + } + } + + setFocusRef = c => { + this.focusedItem = c; + } + + renderItem = (item) => { + const { name, icon, meta, on, text } = item; + + const active = (name === (this.props.value || this.state.value)); + + const computedClass = classNames('composer--options--dropdown--content--item', { + active, + lengthy: meta, + 'toggled-off': !on && on !== null && typeof on !== 'undefined', + 'toggled-on': on, + 'with-icon': icon, + }); + + let prefix = null; + + if (on !== null && typeof on !== 'undefined') { + prefix = <Toggle checked={on} onChange={this.handleClick.bind(this, name)} />; + } else if (icon) { + prefix = <Icon className='icon' fixedWidth id={icon} /> + } + + return ( + <div + className={computedClass} + onClick={this.handleClick.bind(this, name)} + onKeyDown={this.handleKeyDown.bind(this, name)} + role='option' + tabIndex='0' + key={name} + data-index={name} + ref={active ? this.setFocusRef : null} + > + {prefix} + + <div className='content'> + <strong>{text}</strong> + {meta} + </div> + </div> + ); + } + // Rendering. render () { const { mounted } = this.state; @@ -165,7 +214,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent onChange, onClose, style, - value, } = this.props; // The result. @@ -189,27 +237,14 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent <div className='composer--options--dropdown--content' ref={this.handleRef} + role='listbox' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, }} > - {items ? items.map( - ({ - name, - ...rest - }) => ( - <ComposerOptionsDropdownContentItem - active={name === value} - key={name} - name={name} - onChange={onChange} - onClose={onClose} - options={rest} - /> - ) - ) : null} + {!!items && items.map(item => this.renderItem(item))} </div> )} </Motion> diff --git a/app/javascript/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js index 2e29084f2..5b456b717 100644 --- a/app/javascript/flavours/glitch/features/compose/components/header.js +++ b/app/javascript/flavours/glitch/features/compose/components/header.js @@ -53,8 +53,18 @@ class Header extends ImmutablePureComponent { showNotificationsBadge: PropTypes.bool, intl: PropTypes.object, onSettingsClick: PropTypes.func, + onLogout: PropTypes.func.isRequired, }; + handleLogoutClick = e => { + e.preventDefault(); + e.stopPropagation(); + + this.props.onLogout(); + + return false; + } + render () { const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props; @@ -72,13 +82,13 @@ class Header extends ImmutablePureComponent { aria-label={intl.formatMessage(messages.start)} title={intl.formatMessage(messages.start)} to='/getting-started' - ><Icon icon='asterisk' /></Link> + ><Icon id='asterisk' /></Link> {renderForColumn('HOME', ( <Link aria-label={intl.formatMessage(messages.home_timeline)} title={intl.formatMessage(messages.home_timeline)} to='/timelines/home' - ><Icon icon='home' /></Link> + ><Icon id='home' /></Link> ))} {renderForColumn('NOTIFICATIONS', ( <Link @@ -87,7 +97,7 @@ class Header extends ImmutablePureComponent { to='/notifications' > <span className='icon-badge-wrapper'> - <Icon icon='bell' /> + <Icon id='bell' /> { showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />} </span> </Link> @@ -97,27 +107,27 @@ class Header extends ImmutablePureComponent { aria-label={intl.formatMessage(messages.community)} title={intl.formatMessage(messages.community)} to='/timelines/public/local' - ><Icon icon='users' /></Link> + ><Icon id='users' /></Link> ))} {renderForColumn('PUBLIC', ( <Link aria-label={intl.formatMessage(messages.public)} title={intl.formatMessage(messages.public)} to='/timelines/public' - ><Icon icon='globe' /></Link> + ><Icon id='globe' /></Link> ))} <a aria-label={intl.formatMessage(messages.settings)} onClick={onSettingsClick} href='#' title={intl.formatMessage(messages.settings)} - ><Icon icon='cogs' /></a> + ><Icon id='cogs' /></a> <a aria-label={intl.formatMessage(messages.logout)} - data-method='delete' + onClick={this.handleLogoutClick} href={ signOutLink } title={intl.formatMessage(messages.logout)} - ><Icon icon='sign-out' /></a> + ><Icon id='sign-out' /></a> </nav> ); }; diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js index 3148434f1..f6bfbdd1e 100644 --- a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js +++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js @@ -20,16 +20,18 @@ export default class NavigationBar extends ImmutablePureComponent { <Avatar account={this.props.account} size={48} /> </Permalink> - <Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> - <strong>@{this.props.account.get('acct')}</strong> - </Permalink> + <div className='navigation-bar__profile'> + <Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> + <strong>@{this.props.account.get('acct')}</strong> + </Permalink> - { profileLink !== undefined && ( - <a - className='edit' - href={ profileLink } - ><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> - )} + { profileLink !== undefined && ( + <a + className='edit' + href={ profileLink } + ><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> + )} + </div> </div> ); }; diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js index 0c94f5514..92348b000 100644 --- a/app/javascript/flavours/glitch/features/compose/components/options.js +++ b/app/javascript/flavours/glitch/features/compose/components/options.js @@ -7,7 +7,7 @@ import spring from 'react-motion/lib/spring'; // Components. import IconButton from 'flavours/glitch/components/icon_button'; -import TextIconButton from 'flavours/glitch/components/text_icon_button'; +import TextIconButton from './text_icon_button'; import Dropdown from './dropdown'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -232,7 +232,7 @@ class ComposerOptions extends ImmutablePureComponent { const contentTypeItems = { plain: { - icon: 'align-left', + icon: 'file-text', name: 'text/plain', text: <FormattedMessage {...messages.plain} />, }, diff --git a/app/javascript/flavours/glitch/features/compose/components/poll_form.js b/app/javascript/flavours/glitch/features/compose/components/poll_form.js index 21b5d3d73..3d818ea20 100644 --- a/app/javascript/flavours/glitch/features/compose/components/poll_form.js +++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.js @@ -132,7 +132,7 @@ class PollForm extends ImmutablePureComponent { {options.size < pollLimits.max_options && ( <label className='poll__text editable'> <span className={classNames('poll__input')} style={{ opacity: 0 }} /> - <button className='button button-secondary' onClick={this.handleAddOption}><Icon icon='plus' /> <FormattedMessage {...messages.add_option} /></button> + <button className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button> </label> )} </ul> diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js index e283b32b9..b8d9d98bf 100644 --- a/app/javascript/flavours/glitch/features/compose/components/publisher.js +++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js @@ -49,7 +49,6 @@ class Publisher extends ImmutablePureComponent { return ( <div className={computedClass}> - <span className='count'>{diff}</span> {sideArm && sideArm !== 'none' ? ( <Button className='side_arm' @@ -59,7 +58,7 @@ class Publisher extends ImmutablePureComponent { text={ <span> <Icon - icon={{ + id={{ public: 'globe', unlisted: 'unlock', private: 'lock', @@ -81,7 +80,7 @@ class Publisher extends ImmutablePureComponent { return ( <span> <Icon - icon={{ + id={{ direct: 'envelope', private: 'lock', public: 'globe', diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/compose/components/search.js index 1d96933ea..12d839637 100644 --- a/app/javascript/flavours/glitch/features/compose/components/search.js +++ b/app/javascript/flavours/glitch/features/compose/components/search.js @@ -36,7 +36,7 @@ class SearchPopout extends React.PureComponent { <div style={{ ...style, position: 'absolute', width: 285, zIndex: 2 }}> <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> {({ opacity, scaleX, scaleY }) => ( - <div className='drawer--search--popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> + <div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4> <ul> @@ -73,12 +73,17 @@ class Search extends React.PureComponent { onShow: PropTypes.func.isRequired, openInRoute: PropTypes.bool, intl: PropTypes.object.isRequired, + singleColumn: PropTypes.bool, }; state = { expanded: false, }; + setRef = c => { + this.searchForm = c; + } + handleChange = (e) => { const { onChange } = this.props; if (onChange) { @@ -103,10 +108,14 @@ class Search extends React.PureComponent { } handleFocus = () => { - const { onShow } = this.props; this.setState({ expanded: true }); - if (onShow) { - onShow(); + this.props.onShow(); + + if (this.searchForm && !this.props.singleColumn) { + const { left, right } = this.searchForm.getBoundingClientRect(); + if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) { + this.searchForm.scrollIntoView(); + } } } @@ -128,14 +137,15 @@ class Search extends React.PureComponent { render () { const { intl, value, submitted } = this.props; const { expanded } = this.state; - const active = value.length > 0 || submitted; - const computedClass = classNames('drawer--search', { active }); + const hasValue = value.length > 0 || submitted; return ( - <div className={computedClass}> + <div className='search'> <label> <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span> <input + ref={this.setRef} + className='search__input' type='text' placeholder={intl.formatMessage(messages.placeholder)} value={value || ''} @@ -145,17 +155,19 @@ class Search extends React.PureComponent { onBlur={this.handleBlur} /> </label> + <div aria-label={intl.formatMessage(messages.placeholder)} - className='icon' + className='search__icon' onClick={this.handleClear} role='button' tabIndex='0' > - <Icon icon='search' /> - <Icon icon='times-circle' /> + <Icon id='search' className={hasValue ? '' : 'active'} /> + <Icon id='times-circle' className={hasValue ? 'active' : ''} /> </div> - <Overlay show={expanded && !active} placement='bottom' target={this}> + + <Overlay show={expanded && !hasValue} placement='bottom' target={this}> <SearchPopout /> </Overlay> </div> diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js index 69df8cdc9..fa3487328 100644 --- a/app/javascript/flavours/glitch/features/compose/components/search_results.js +++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js @@ -7,6 +7,8 @@ import StatusContainer from 'flavours/glitch/containers/status_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Hashtag from 'flavours/glitch/components/hashtag'; import Icon from 'flavours/glitch/components/icon'; +import { searchEnabled } from 'flavours/glitch/util/initial_state'; +import LoadMore from 'flavours/glitch/components/load_more'; const messages = defineMessages({ dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' }, @@ -19,23 +21,33 @@ class SearchResults extends ImmutablePureComponent { results: ImmutablePropTypes.map.isRequired, suggestions: ImmutablePropTypes.list.isRequired, fetchSuggestions: PropTypes.func.isRequired, + expandSearch: PropTypes.func.isRequired, dismissSuggestion: PropTypes.func.isRequired, + searchTerm: PropTypes.string, intl: PropTypes.object.isRequired, }; componentDidMount () { - this.props.fetchSuggestions(); + if (this.props.searchTerm === '') { + this.props.fetchSuggestions(); + } } - render() { - const { intl, results, suggestions, dismissSuggestion } = this.props; + handleLoadMoreAccounts = () => this.props.expandSearch('accounts'); + + handleLoadMoreStatuses = () => this.props.expandSearch('statuses'); + + handleLoadMoreHashtags = () => this.props.expandSearch('hashtags'); + + render () { + const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props; if (results.isEmpty() && !suggestions.isEmpty()) { return ( <div className='drawer--results'> <div className='trends'> <div className='trends__header'> - <i className='fa fa-user-plus fa-fw' /> + <Icon fixedWidth id='user-plus' /> <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' /> </div> @@ -51,6 +63,16 @@ class SearchResults extends ImmutablePureComponent { </div> </div> ); + } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) { + statuses = ( + <section> + <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5> + + <div className='search-results__info'> + <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' /> + </div> + </section> + ); } let accounts, statuses, hashtags; @@ -60,9 +82,11 @@ class SearchResults extends ImmutablePureComponent { count += results.get('accounts').size; accounts = ( <section> - <h5><Icon icon='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5> + <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5> {results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)} + + {results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />} </section> ); } @@ -71,9 +95,11 @@ class SearchResults extends ImmutablePureComponent { count += results.get('statuses').size; statuses = ( <section> - <h5><Icon icon='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5> + <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5> {results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId}/>)} + + {results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />} </section> ); } @@ -82,9 +108,11 @@ class SearchResults extends ImmutablePureComponent { count += results.get('hashtags').size; hashtags = ( <section> - <h5><Icon icon='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5> + <h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5> {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} + + {results.get('hashtags').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreHashtags} />} </section> ); } @@ -93,7 +121,7 @@ class SearchResults extends ImmutablePureComponent { return ( <div className='drawer--results'> <header className='search-results__header'> - <Icon icon='search' fixedWidth /> + <Icon id='search' fixedWidth /> <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} /> </header> diff --git a/app/javascript/flavours/glitch/components/text_icon_button.js b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js index 9c8ffab1f..7f2005060 100644 --- a/app/javascript/flavours/glitch/components/text_icon_button.js +++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js @@ -1,6 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; +const iconStyle = { + height: null, + lineHeight: '27px', + width: `${18 * 1.28571429}px`, +}; + export default class TextIconButton extends React.PureComponent { static propTypes = { @@ -20,7 +26,15 @@ export default class TextIconButton extends React.PureComponent { const { label, title, active, ariaControls } = this.props; return ( - <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}> + <button + title={title} + aria-label={title} + className={`text-icon-button ${active ? 'active' : ''}`} + aria-expanded={active} + onClick={this.handleClick} + aria-controls={ariaControls} + style={iconStyle} + > {label} </button> ); diff --git a/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js index ec696f9c3..b875fb15e 100644 --- a/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js +++ b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js @@ -47,8 +47,8 @@ class TextareaIcons extends ImmutablePureComponent { title={intl.formatMessage(message)} > <Icon - fullwidth - icon={icon} + fixedWidth + id={icon} /> </span> ) : null diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js index 84edf664e..425b0fe5e 100644 --- a/app/javascript/flavours/glitch/features/compose/components/upload.js +++ b/app/javascript/flavours/glitch/features/compose/components/upload.js @@ -4,18 +4,12 @@ import PropTypes from 'prop-types'; import Motion from 'flavours/glitch/util/optional_motion'; import spring from 'react-motion/lib/spring'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import Icon from 'flavours/glitch/components/icon'; import { isUserTouching } from 'flavours/glitch/util/is_mobile'; -const messages = defineMessages({ - description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, -}); - -// The component. -export default @injectIntl -class Upload extends ImmutablePureComponent { +export default class Upload extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -23,30 +17,10 @@ class Upload extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, - intl: PropTypes.object.isRequired, onUndo: PropTypes.func.isRequired, - onDescriptionChange: PropTypes.func.isRequired, onOpenFocalPoint: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - }; - - state = { - hovered: false, - focused: false, - dirtyDescription: null, }; - handleKeyDown = (e) => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - this.handleSubmit(); - } - } - - handleSubmit = () => { - this.handleInputBlur(); - this.props.onSubmit(this.context.router.history); - } - handleUndoClick = e => { e.stopPropagation(); this.props.onUndo(this.props.media.get('id')); @@ -57,69 +31,21 @@ class Upload extends ImmutablePureComponent { this.props.onOpenFocalPoint(this.props.media.get('id')); } - handleInputChange = e => { - this.setState({ dirtyDescription: e.target.value }); - } - - handleMouseEnter = () => { - this.setState({ hovered: true }); - } - - handleMouseLeave = () => { - this.setState({ hovered: false }); - } - - handleInputFocus = () => { - this.setState({ focused: true }); - } - - handleClick = () => { - this.setState({ focused: true }); - } - - handleInputBlur = () => { - const { dirtyDescription } = this.state; - - this.setState({ focused: false, dirtyDescription: null }); - - if (dirtyDescription !== null) { - this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription); - } - } - render () { const { intl, media } = this.props; - const active = this.state.hovered || this.state.focused || isUserTouching(); - const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || ''; - const computedClass = classNames('composer--upload_form--item', { active }); const focusX = media.getIn(['meta', 'focus', 'x']); const focusY = media.getIn(['meta', 'focus', 'y']); const x = ((focusX / 2) + .5) * 100; const y = ((focusY / -2) + .5) * 100; return ( - <div className={computedClass} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'> + <div className='composer--upload_form--item' tabIndex='0' role='button'> <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12, }) }}> {({ scale }) => ( <div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}> - <div className={classNames('composer--upload_form--actions', { active })}> - <button className='icon-button' onClick={this.handleUndoClick}><Icon icon='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button> - {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>} - </div> - - <div className={classNames('composer--upload_form--description', { active })}> - <label> - <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span> - <textarea - placeholder={intl.formatMessage(messages.description)} - value={description} - maxLength={420} - onFocus={this.handleInputFocus} - onChange={this.handleInputChange} - onBlur={this.handleInputBlur} - onKeyDown={this.handleKeyDown} - /> - </label> + <div className={classNames('composer--upload_form--actions', { active: true })}> + <button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button> + <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button> </div> </div> )} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js index 35880ddcc..43039c674 100644 --- a/app/javascript/flavours/glitch/features/compose/components/upload_form.js +++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js @@ -4,6 +4,7 @@ import UploadProgressContainer from '../containers/upload_progress_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; import UploadContainer from '../containers/upload_container'; import SensitiveButtonContainer from '../containers/sensitive_button_container'; +import { FormattedMessage } from 'react-intl'; export default class UploadForm extends ImmutablePureComponent { static propTypes = { @@ -15,7 +16,7 @@ export default class UploadForm extends ImmutablePureComponent { return ( <div className='composer--upload_form'> - <UploadProgressContainer /> + <UploadProgressContainer icon='upload' message={<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />} /> {mediaIds.size > 0 && ( <div className='content'> diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js index 264c563f2..493bb9ca5 100644 --- a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js +++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js @@ -2,7 +2,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import Motion from 'flavours/glitch/util/optional_motion'; import spring from 'react-motion/lib/spring'; -import { FormattedMessage } from 'react-intl'; import Icon from 'flavours/glitch/components/icon'; export default class UploadProgress extends React.PureComponent { @@ -10,10 +9,12 @@ export default class UploadProgress extends React.PureComponent { static propTypes = { active: PropTypes.bool, progress: PropTypes.number, + icon: PropTypes.string.isRequired, + message: PropTypes.node.isRequired, }; render () { - const { active, progress } = this.props; + const { active, progress, icon, message } = this.props; if (!active) { return null; @@ -21,10 +22,10 @@ export default class UploadProgress extends React.PureComponent { return ( <div className='composer--upload_form--progress'> - <Icon icon='upload' /> + <Icon id={icon} /> <div className='message'> - <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' /> + {message} <div className='backdrop'> <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js index 199d43913..18e2b2f39 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js @@ -25,6 +25,8 @@ const messages = defineMessages({ defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' }, missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm', defaultMessage: 'Send anyway' }, + missingDescriptionEdit: { id: 'confirmations.missing_media_description.edit', + defaultMessage: 'Edit media' }, }); // State mapping. @@ -112,11 +114,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(changeComposeVisibility(value)); }, - onMediaDescriptionConfirm(routerHistory) { + onMediaDescriptionConfirm(routerHistory, mediaId) { dispatch(openModal('CONFIRM', { message: intl.formatMessage(messages.missingDescriptionMessage), confirm: intl.formatMessage(messages.missingDescriptionConfirm), onConfirm: () => dispatch(submitCompose(routerHistory)), + secondary: intl.formatMessage(messages.missingDescriptionEdit), + onSecondary: () => dispatch(openModal('FOCAL_POINT', { id: mediaId })), onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)), })); }, diff --git a/app/javascript/flavours/glitch/features/compose/containers/header_container.js b/app/javascript/flavours/glitch/features/compose/containers/header_container.js index ce1dea319..b4dcb4d56 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/header_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js @@ -1,6 +1,13 @@ import { openModal } from 'flavours/glitch/actions/modal'; import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; import Header from '../components/header'; +import { logOut } from 'flavours/glitch/util/log_out'; + +const messages = defineMessages({ + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, +}); const mapStateToProps = state => { return { @@ -16,6 +23,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ e.stopPropagation(); dispatch(openModal('SETTINGS', {})); }, + onLogout () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + onConfirm: () => logOut(), + })); + }, }); -export default connect(mapStateToProps, mapDispatchToProps)(Header); +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/flavours/glitch/features/compose/containers/options_container.js b/app/javascript/flavours/glitch/features/compose/containers/options_container.js index c8c7ecd43..c792aa582 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/options_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/options_container.js @@ -12,11 +12,12 @@ function mapStateToProps (state) { const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']); const poll = state.getIn(['compose', 'poll']); const media = state.getIn(['compose', 'media_attachments']); + const pending_media = state.getIn(['compose', 'pending_media_attachments']); return { acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','), resetFileKey: state.getIn(['compose', 'resetFileKey']), hasPoll: !!poll, - allowMedia: !poll && (media ? media.size < 4 && !media.some(item => item.get('type') === 'video') : true), + allowMedia: !poll && (media ? media.size + pending_media < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : pending_media < 4), hasMedia: media && !!media.size, allowPoll: !(media && !!media.size), showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']), diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js index f9637861a..5c2c1be23 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js @@ -1,14 +1,17 @@ import { connect } from 'react-redux'; import SearchResults from '../components/search_results'; -import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestions'; +import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions'; +import { expandSearch } from 'flavours/glitch/actions/search'; const mapStateToProps = state => ({ results: state.getIn(['search', 'results']), suggestions: state.getIn(['suggestions', 'items']), + searchTerm: state.getIn(['search', 'searchTerm']), }); const mapDispatchToProps = dispatch => ({ fetchSuggestions: () => dispatch(fetchSuggestions()), + expandSearch: type => dispatch(expandSearch(type)), dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))), }); diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js index d6bff63ac..f687fae99 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import Upload from '../components/upload'; -import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/actions/compose'; +import { undoUploadCompose } from 'flavours/glitch/actions/compose'; import { openModal } from 'flavours/glitch/actions/modal'; import { submitCompose } from 'flavours/glitch/actions/compose'; @@ -14,10 +14,6 @@ const mapDispatchToProps = dispatch => ({ dispatch(undoUploadCompose(id)); }, - onDescriptionChange: (id, description) => { - dispatch(changeUploadCompose(id, { description })); - }, - onOpenFocalPoint: id => { dispatch(openModal('FOCAL_POINT', { id })); }, diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js index fdd21f114..b9b0a2644 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js @@ -4,6 +4,7 @@ import Warning from '../components/warning'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; import { me } from 'flavours/glitch/util/initial_state'; +import { profileLink, termsLink } from 'flavours/glitch/util/backend_links'; const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i; @@ -15,7 +16,7 @@ const mapStateToProps = state => ({ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => { if (needsLockWarning) { - return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; + return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href={profileLink}><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; } if (hashtagWarning) { @@ -25,7 +26,7 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning if (directMessageWarning) { const message = ( <span> - <FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a> + <FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> {!!termsLink && <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>} </span> ); diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.js index 5adb44f2c..ce14e2a9d 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.js +++ b/app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.js @@ -9,8 +9,8 @@ const messages = defineMessages({ settings: { id: 'home.settings', defaultMessage: 'Column settings' }, }); -@injectIntl -export default class ColumnSettings extends React.PureComponent { +export default @injectIntl +class ColumnSettings extends React.PureComponent { static propTypes = { settings: ImmutablePropTypes.map.isRequired, diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js index 9ddeabe75..ba01f8d5c 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js +++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js @@ -2,9 +2,30 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import StatusContainer from 'flavours/glitch/containers/status_container'; +import StatusContent from 'flavours/glitch/components/status_content'; +import AttachmentList from 'flavours/glitch/components/attachment_list'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; +import AvatarComposite from 'flavours/glitch/components/avatar_composite'; +import Permalink from 'flavours/glitch/components/permalink'; +import IconButton from 'flavours/glitch/components/icon_button'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import { HotKeys } from 'react-hotkeys'; +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; +import classNames from 'classnames'; -export default class Conversation extends ImmutablePureComponent { +const messages = defineMessages({ + more: { id: 'status.more', defaultMessage: 'More' }, + open: { id: 'conversation.open', defaultMessage: 'View conversation' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + markAsRead: { id: 'conversation.mark_as_read', defaultMessage: 'Mark as read' }, + delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, +}); + +export default @injectIntl +class Conversation extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -13,25 +34,99 @@ export default class Conversation extends ImmutablePureComponent { static propTypes = { conversationId: PropTypes.string.isRequired, accounts: ImmutablePropTypes.list.isRequired, - lastStatusId: PropTypes.string, + lastStatus: ImmutablePropTypes.map, unread:PropTypes.bool.isRequired, onMoveUp: PropTypes.func, onMoveDown: PropTypes.func, markRead: PropTypes.func.isRequired, + delete: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + isExpanded: undefined, }; + parseClick = (e, destination) => { + const { router } = this.context; + const { lastStatus, unread, markRead } = this.props; + if (!router) return; + + if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) { + if (destination === undefined) { + if (unread) { + markRead(); + } + destination = `/statuses/${lastStatus.get('id')}`; + } + let state = {...router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + router.history.push(destination, state); + e.preventDefault(); + } + } + + _updateEmojis () { + const node = this.namesNode; + + if (!node || autoPlayGif) { + return; + } + + const emojis = node.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + if (emoji.classList.contains('status-emoji')) { + continue; + } + emoji.classList.add('status-emoji'); + + emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false); + emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false); + } + } + + componentDidMount () { + this._updateEmojis(); + } + + componentDidUpdate () { + this._updateEmojis(); + } + + handleEmojiMouseEnter = ({ target }) => { + target.src = target.getAttribute('data-original'); + } + + handleEmojiMouseLeave = ({ target }) => { + target.src = target.getAttribute('data-static'); + } + handleClick = () => { if (!this.context.router) { return; } - const { lastStatusId, unread, markRead } = this.props; + const { lastStatus, unread, markRead } = this.props; if (unread) { markRead(); } - this.context.router.history.push(`/statuses/${lastStatusId}`); + this.context.router.history.push(`/statuses/${lastStatus.get('id')}`); + } + + handleMarkAsRead = () => { + this.props.markRead(); + } + + handleReply = () => { + this.props.reply(this.props.lastStatus, this.context.router.history); + } + + handleDelete = () => { + this.props.delete(); } handleHotkeyMoveUp = () => { @@ -42,22 +137,98 @@ export default class Conversation extends ImmutablePureComponent { this.props.onMoveDown(this.props.conversationId); } + handleConversationMute = () => { + this.props.onMute(this.props.lastStatus); + } + + handleShowMore = () => { + if (this.props.lastStatus.get('spoiler_text')) { + this.setExpansion(!this.state.isExpanded); + } + }; + + setExpansion = value => { + this.setState({ isExpanded: value }); + } + + setNamesRef = (c) => { + this.namesNode = c; + } + render () { - const { accounts, lastStatusId, unread } = this.props; + const { accounts, lastStatus, unread, intl } = this.props; + const { isExpanded } = this.state; - if (lastStatusId === null) { + if (lastStatus === null) { return null; } + const menu = [ + { text: intl.formatMessage(messages.open), action: this.handleClick }, + null, + ]; + + menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute }); + + if (unread) { + menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead }); + menu.push(null); + } + + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete }); + + const names = accounts.map(a => <Permalink to={`/accounts/${a.get('id')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]); + + const handlers = { + reply: this.handleReply, + open: this.handleClick, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + toggleHidden: this.handleShowMore, + }; + + let media = null; + if (lastStatus.get('media_attachments').size > 0) { + media = <AttachmentList compact media={lastStatus.get('media_attachments')} />; + } + return ( - <StatusContainer - id={lastStatusId} - unread={unread} - otherAccounts={accounts} - onMoveUp={this.handleHotkeyMoveUp} - onMoveDown={this.handleHotkeyMoveDown} - onClick={this.handleClick} - /> + <HotKeys handlers={handlers}> + <div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex='0'> + <div className='conversation__avatar'> + <AvatarComposite accounts={accounts} size={48} /> + </div> + + <div className='conversation__content'> + <div className='conversation__content__info'> + <div className='conversation__content__relative-time'> + {unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} /> + </div> + + <div className='conversation__content__names' ref={this.setNamesRef}> + <FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} /> + </div> + </div> + + <StatusContent + status={lastStatus} + parseClick={this.parseClick} + expanded={isExpanded} + onExpandedToggle={this.handleShowMore} + collapsable + media={media} + /> + + <div className='status__action-bar'> + <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReply} /> + + <div className='status__action-bar-dropdown'> + <DropdownMenuContainer status={lastStatus} items={menu} icon='ellipsis-h' size={18} direction='right' title={intl.formatMessage(messages.more)} /> + </div> + </div> + </div> + </div> + </HotKeys> ); } diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js index bd6f6bfb0..b15ce9f0f 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js +++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js @@ -1,19 +1,74 @@ import { connect } from 'react-redux'; import Conversation from '../components/conversation'; -import { markConversationRead } from '../../../actions/conversations'; +import { markConversationRead, deleteConversation } from 'flavours/glitch/actions/conversations'; +import { makeGetStatus } from 'flavours/glitch/selectors'; +import { replyCompose } from 'flavours/glitch/actions/compose'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'flavours/glitch/actions/statuses'; +import { defineMessages, injectIntl } from 'react-intl'; -const mapStateToProps = (state, { conversationId }) => { - const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId); +const messages = defineMessages({ + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, +}); + +const mapStateToProps = () => { + const getStatus = makeGetStatus(); + + return (state, { conversationId }) => { + const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId); + const lastStatusId = conversation.get('last_status', null); - return { - accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)), - unread: conversation.get('unread'), - lastStatusId: conversation.get('last_status', null), + return { + accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)), + unread: conversation.get('unread'), + lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }), + }; }; }; -const mapDispatchToProps = (dispatch, { conversationId }) => ({ - markRead: () => dispatch(markConversationRead(conversationId)), +const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({ + + markRead () { + dispatch(markConversationRead(conversationId)); + }, + + reply (status, router) { + dispatch((_, getState) => { + let state = getState(); + + if (state.getIn(['compose', 'text']).trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: () => dispatch(replyCompose(status, router)), + })); + } else { + dispatch(replyCompose(status, router)); + } + }); + }, + + delete () { + dispatch(deleteConversation(conversationId)); + }, + + onMute (status) { + if (status.get('muted')) { + dispatch(unmuteStatus(status.get('id'))); + } else { + dispatch(muteStatus(status.get('id'))); + } + }, + + onToggleHidden (status) { + if (status.get('hidden')) { + dispatch(revealStatus(status.get('id'))); + } else { + dispatch(hideStatus(status.get('id'))); + } + }, + }); -export default connect(mapStateToProps, mapDispatchToProps)(Conversation); +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation)); diff --git a/app/javascript/flavours/glitch/features/direct_timeline/index.js b/app/javascript/flavours/glitch/features/direct_timeline/index.js index 6fe8a1ce8..7741c6922 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/index.js +++ b/app/javascript/flavours/glitch/features/direct_timeline/index.js @@ -22,9 +22,9 @@ const mapStateToProps = state => ({ conversationsMode: state.getIn(['settings', 'direct', 'conversations']), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class DirectTimeline extends React.PureComponent { +class DirectTimeline extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, @@ -135,7 +135,7 @@ export default class DirectTimeline extends React.PureComponent { } return ( - <Column ref={this.setRef} label={intl.formatMessage(messages.title)}> + <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> <ColumnHeader icon='envelope' active={hasUnread} diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.js b/app/javascript/flavours/glitch/features/directory/components/account_card.js new file mode 100644 index 000000000..d1c406933 --- /dev/null +++ b/app/javascript/flavours/glitch/features/directory/components/account_card.js @@ -0,0 +1,190 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import Permalink from 'flavours/glitch/components/permalink'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; +import { autoPlayGif, me, unfollowModal } from 'flavours/glitch/util/initial_state'; +import { shortNumberFormat } from 'flavours/glitch/util/numbers'; +import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'flavours/glitch/actions/accounts'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { initMuteModal } from 'flavours/glitch/actions/mutes'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { id }) => ({ + account: getAccount(state, id), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onFollow (account) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { + if (unfollowModal) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.unfollowConfirm), + onConfirm: () => dispatch(unfollowAccount(account.get('id'))), + })); + } else { + dispatch(unfollowAccount(account.get('id'))); + } + } else { + dispatch(followAccount(account.get('id'))); + } + }, + + onBlock (account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + dispatch(blockAccount(account.get('id'))); + } + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(initMuteModal(account)); + } + }, + +}); + +export default @injectIntl +@connect(makeMapStateToProps, mapDispatchToProps) +class AccountCard extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + }; + + _updateEmojis () { + const node = this.node; + + if (!node || autoPlayGif) { + return; + } + + const emojis = node.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + if (emoji.classList.contains('status-emoji')) { + continue; + } + emoji.classList.add('status-emoji'); + + emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false); + emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false); + } + } + + componentDidMount () { + this._updateEmojis(); + } + + componentDidUpdate () { + this._updateEmojis(); + } + + handleEmojiMouseEnter = ({ target }) => { + target.src = target.getAttribute('data-original'); + } + + handleEmojiMouseLeave = ({ target }) => { + target.src = target.getAttribute('data-static'); + } + + handleFollow = () => { + this.props.onFollow(this.props.account); + } + + handleBlock = () => { + this.props.onBlock(this.props.account); + } + + handleMute = () => { + this.props.onMute(this.props.account); + } + + setRef = (c) => { + this.node = c; + } + + render () { + const { account, intl } = this.props; + + let buttons; + + if (account.get('id') !== me && account.get('relationship', null) !== null) { + const following = account.getIn(['relationship', 'following']); + const requested = account.getIn(['relationship', 'requested']); + const blocking = account.getIn(['relationship', 'blocking']); + const muting = account.getIn(['relationship', 'muting']); + + if (requested) { + buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />; + } else if (blocking) { + buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; + } else if (muting) { + buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />; + } else if (!account.get('moved') || following) { + buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; + } + } + + return ( + <div className='directory__card'> + <div className='directory__card__img'> + <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' /> + </div> + + <div className='directory__card__bar'> + <Permalink className='directory__card__bar__name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> + <Avatar account={account} size={48} /> + <DisplayName account={account} /> + </Permalink> + + <div className='directory__card__bar__relationship account__relationship'> + {buttons} + </div> + </div> + + <div className='directory__card__extra' ref={this.setRef}> + <div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} /> + </div> + + <div className='directory__card__extra'> + <div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div> + <div className='accounts-table__count'>{account.get('followers_count') < 0 ? '-' : shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div> + <div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/directory/index.js b/app/javascript/flavours/glitch/features/directory/index.js new file mode 100644 index 000000000..858a8fa55 --- /dev/null +++ b/app/javascript/flavours/glitch/features/directory/index.js @@ -0,0 +1,171 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'flavours/glitch/actions/columns'; +import { fetchDirectory, expandDirectory } from 'flavours/glitch/actions/directory'; +import { List as ImmutableList } from 'immutable'; +import AccountCard from './components/account_card'; +import RadioButton from 'flavours/glitch/components/radio_button'; +import classNames from 'classnames'; +import LoadMore from 'flavours/glitch/components/load_more'; +import { ScrollContainer } from 'react-router-scroll-4'; + +const messages = defineMessages({ + title: { id: 'column.directory', defaultMessage: 'Browse profiles' }, + recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' }, + newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' }, + local: { id: 'directory.local', defaultMessage: 'From {domain} only' }, + federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()), + isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true), + domain: state.getIn(['meta', 'domain']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Directory extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + isLoading: PropTypes.bool, + accountIds: ImmutablePropTypes.list.isRequired, + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + domain: PropTypes.string.isRequired, + params: PropTypes.shape({ + order: PropTypes.string, + local: PropTypes.bool, + }), + }; + + state = { + order: null, + local: null, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state))); + } + } + + getParams = (props, state) => ({ + order: state.order === null ? (props.params.order || 'active') : state.order, + local: state.local === null ? (props.params.local || false) : state.local, + }); + + handleMove = dir => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchDirectory(this.getParams(this.props, this.state))); + } + + componentDidUpdate (prevProps, prevState) { + const { dispatch } = this.props; + const paramsOld = this.getParams(prevProps, prevState); + const paramsNew = this.getParams(this.props, this.state); + + if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) { + dispatch(fetchDirectory(paramsNew)); + } + } + + setRef = c => { + this.column = c; + } + + handleChangeOrder = e => { + const { dispatch, columnId } = this.props; + + if (columnId) { + dispatch(changeColumnParams(columnId, ['order'], e.target.value)); + } else { + this.setState({ order: e.target.value }); + } + } + + handleChangeLocal = e => { + const { dispatch, columnId } = this.props; + + if (columnId) { + dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1')); + } else { + this.setState({ local: e.target.value === '1' }); + } + } + + handleLoadMore = () => { + const { dispatch } = this.props; + dispatch(expandDirectory(this.getParams(this.props, this.state))); + } + + render () { + const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props; + const { order, local } = this.getParams(this.props, this.state); + const pinned = !!columnId; + + const scrollableArea = ( + <div className='scrollable' style={{ background: 'transparent' }}> + <div className='filter-form'> + <div className='filter-form__column' role='group'> + <RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} /> + <RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} /> + </div> + + <div className='filter-form__column' role='group'> + <RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} /> + <RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} /> + </div> + </div> + + <div className={classNames('directory__list', { loading: isLoading })}> + {accountIds.map(accountId => <AccountCard id={accountId} key={accountId} />)} + </div> + + <LoadMore onClick={this.handleLoadMore} visible={!isLoading} /> + </div> + ); + + return ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon='address-book-o' + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + /> + + {multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea} + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/domain_blocks/index.js b/app/javascript/flavours/glitch/features/domain_blocks/index.js index 3b29e2a26..cd105a49b 100644 --- a/app/javascript/flavours/glitch/features/domain_blocks/index.js +++ b/app/javascript/flavours/glitch/features/domain_blocks/index.js @@ -1,16 +1,16 @@ import React from 'react'; import { connect } from 'react-redux'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import PropTypes from 'prop-types'; import LoadingIndicator from '../../components/loading_indicator'; import Column from '../ui/components/column'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import DomainContainer from '../../containers/domain_container'; import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { debounce } from 'lodash'; -import ScrollableList from '../../components/scrollable_list'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; const messages = defineMessages({ heading: { id: 'column.domain_blocks', defaultMessage: 'Hidden domains' }, @@ -19,17 +19,20 @@ const messages = defineMessages({ const mapStateToProps = state => ({ domains: state.getIn(['domain_lists', 'blocks', 'items']), + hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class Blocks extends ImmutablePureComponent { +class Blocks extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, + hasMore: PropTypes.bool, domains: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -41,7 +44,7 @@ export default class Blocks extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { intl, domains } = this.props; + const { intl, domains, hasMore, multiColumn } = this.props; if (!domains) { return ( @@ -51,10 +54,18 @@ export default class Blocks extends ImmutablePureComponent { ); } + const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no hidden domains yet.' />; + return ( - <Column icon='minus-circle' heading={intl.formatMessage(messages.heading)}> + <Column bindToDocument={!multiColumn} icon='minus-circle' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> - <ScrollableList scrollKey='domain_blocks' onLoadMore={this.handleLoadMore}> + <ScrollableList + scrollKey='domain_blocks' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > {domains.map(domain => <DomainContainer key={domain} domain={domain} /> )} diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js index a78117971..6e5518b0c 100644 --- a/app/javascript/flavours/glitch/features/emoji_picker/index.js +++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js @@ -11,7 +11,8 @@ import Overlay from 'react-overlays/lib/Overlay'; import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import detectPassiveEvents from 'detect-passive-events'; -import { buildCustomEmojis } from 'flavours/glitch/util/emoji'; +import { buildCustomEmojis, categoriesFromEmojis } from 'flavours/glitch/util/emoji'; +import { useSystemEmojiFont } from 'flavours/glitch/util/initial_state'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, @@ -110,19 +111,6 @@ let EmojiPicker, Emoji; // load asynchronously const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; -const categoriesSort = [ - 'recent', - 'custom', - 'people', - 'nature', - 'foods', - 'activity', - 'places', - 'objects', - 'symbols', - 'flags', -]; - class ModifierPickerMenu extends React.PureComponent { static propTypes = { @@ -172,12 +160,12 @@ class ModifierPickerMenu extends React.PureComponent { return ( <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}> - <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button> - <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button> - <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button> - <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button> - <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button> - <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> </div> ); } @@ -212,7 +200,7 @@ class ModifierPicker extends React.PureComponent { return ( <div className='emoji-picker-dropdown__modifiers'> - <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} /> + <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /> <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} /> </div> ); @@ -320,8 +308,23 @@ class EmojiPickerMenu extends React.PureComponent { } const title = intl.formatMessage(messages.emoji); + const { modifierOpen } = this.state; + const categoriesSort = [ + 'recent', + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'objects', + 'symbols', + 'flags', + ]; + + categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort()); + return ( <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> <EmojiPicker @@ -342,6 +345,7 @@ class EmojiPickerMenu extends React.PureComponent { backgroundImageFn={backgroundImageFn} autoFocus emojiTooltip + native={useSystemEmojiFont} /> <ModifierPicker @@ -357,9 +361,9 @@ class EmojiPickerMenu extends React.PureComponent { } -@connect(mapStateToProps, mapDispatchToProps) +export default @connect(mapStateToProps, mapDispatchToProps) @injectIntl -export default class EmojiPickerDropdown extends React.PureComponent { +class EmojiPickerDropdown extends React.PureComponent { static propTypes = { custom_emojis: ImmutablePropTypes.list, diff --git a/app/javascript/flavours/glitch/features/favourited_statuses/index.js b/app/javascript/flavours/glitch/features/favourited_statuses/index.js index 32bf4e71a..c6470ba74 100644 --- a/app/javascript/flavours/glitch/features/favourited_statuses/index.js +++ b/app/javascript/flavours/glitch/features/favourited_statuses/index.js @@ -2,14 +2,14 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'flavours/glitch/actions/favourites'; import Column from 'flavours/glitch/features/ui/components/column'; import ColumnHeader from 'flavours/glitch/components/column_header'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import StatusList from 'flavours/glitch/components/status_list'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { debounce } from 'lodash'; const messages = defineMessages({ heading: { id: 'column.favourites', defaultMessage: 'Favourites' }, @@ -21,9 +21,9 @@ const mapStateToProps = state => ({ hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class Favourites extends ImmutablePureComponent { +class Favourites extends ImmutablePureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, @@ -70,8 +70,10 @@ export default class Favourites extends ImmutablePureComponent { const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; const pinned = !!columnId; + const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite toots yet. When you favourite one, it will show up here." />; + return ( - <Column ref={this.setRef} name='favourites' label={intl.formatMessage(messages.heading)}> + <Column bindToDocument={!multiColumn} ref={this.setRef} name='favourites' label={intl.formatMessage(messages.heading)}> <ColumnHeader icon='star' title={intl.formatMessage(messages.heading)} @@ -90,6 +92,8 @@ export default class Favourites extends ImmutablePureComponent { hasMore={hasMore} isLoading={isLoading} onLoadMore={this.handleLoadMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/flavours/glitch/features/favourites/index.js b/app/javascript/flavours/glitch/features/favourites/index.js index eb86636c3..953bf171f 100644 --- a/app/javascript/flavours/glitch/features/favourites/index.js +++ b/app/javascript/flavours/glitch/features/favourites/index.js @@ -4,34 +4,39 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; import { fetchFavourites } from 'flavours/glitch/actions/interactions'; -import { ScrollContainer } from 'react-router-scroll-4'; import AccountContainer from 'flavours/glitch/containers/account_container'; import Column from 'flavours/glitch/features/ui/components/column'; +import Icon from 'flavours/glitch/components/icon'; import ColumnHeader from 'flavours/glitch/components/column_header'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from '../../components/scrollable_list'; const messages = defineMessages({ heading: { id: 'column.favourited_by', defaultMessage: 'Favourited by' }, + refresh: { id: 'refresh', defaultMessage: 'Refresh' }, }); const mapStateToProps = (state, props) => ({ accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class Favourites extends ImmutablePureComponent { +class Favourites extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, accountIds: ImmutablePropTypes.list, + multiColumn: PropTypes.bool, intl: PropTypes.object.isRequired, }; componentWillMount () { - this.props.dispatch(fetchFavourites(this.props.params.statusId)); + if (!this.props.accountIds) { + this.props.dispatch(fetchFavourites(this.props.params.statusId)); + } } componentWillReceiveProps (nextProps) { @@ -40,11 +45,6 @@ export default class Favourites extends ImmutablePureComponent { } } - shouldUpdateScroll = (prevRouterProps, { location }) => { - if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; - return !(location.state && location.state.mastodonModalOpen); - } - handleHeaderClick = () => { this.column.scrollTop(); } @@ -53,8 +53,12 @@ export default class Favourites extends ImmutablePureComponent { this.column = c; } + handleRefresh = () => { + this.props.dispatch(fetchFavourites(this.props.params.statusId)); + } + render () { - const { intl, accountIds } = this.props; + const { intl, accountIds, multiColumn } = this.props; if (!accountIds) { return ( @@ -64,6 +68,8 @@ export default class Favourites extends ImmutablePureComponent { ); } + const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has favourited this toot yet. When someone does, they will show up here.' />; + return ( <Column ref={this.setRef}> <ColumnHeader @@ -71,13 +77,20 @@ export default class Favourites extends ImmutablePureComponent { title={intl.formatMessage(messages.heading)} onClick={this.handleHeaderClick} showBackButton + multiColumn={multiColumn} + extraButton={( + <button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button> + )} /> - - <ScrollContainer scrollKey='favourites' shouldUpdateScroll={this.shouldUpdateScroll}> - <div className='scrollable'> - {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} - </div> - </ScrollContainer> + <ScrollableList + scrollKey='favourites' + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} withNote={false} /> + )} + </ScrollableList> </Column> ); } diff --git a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js index dead0753f..bf145cb67 100644 --- a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js +++ b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js @@ -13,8 +13,8 @@ const messages = defineMessages({ reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, }); -@injectIntl -export default class AccountAuthorize extends ImmutablePureComponent { +export default @injectIntl +class AccountAuthorize extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, diff --git a/app/javascript/flavours/glitch/features/follow_requests/index.js b/app/javascript/flavours/glitch/features/follow_requests/index.js index d0845769e..36770aace 100644 --- a/app/javascript/flavours/glitch/features/follow_requests/index.js +++ b/app/javascript/flavours/glitch/features/follow_requests/index.js @@ -2,14 +2,15 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; -import { ScrollContainer } from 'react-router-scroll-4'; import Column from 'flavours/glitch/features/ui/components/column'; import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; import AccountAuthorizeContainer from './containers/account_authorize_container'; import { fetchFollowRequests, expandFollowRequests } from 'flavours/glitch/actions/accounts'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; const messages = defineMessages({ heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }, @@ -17,38 +18,32 @@ const messages = defineMessages({ const mapStateToProps = state => ({ accountIds: state.getIn(['user_lists', 'follow_requests', 'items']), + hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class FollowRequests extends ImmutablePureComponent { +class FollowRequests extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, + hasMore: PropTypes.bool, accountIds: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, }; componentWillMount () { this.props.dispatch(fetchFollowRequests()); } - handleScroll = (e) => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - - if (scrollTop === scrollHeight - clientHeight) { - this.props.dispatch(expandFollowRequests()); - } - } - - shouldUpdateScroll = (prevRouterProps, { location }) => { - if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; - return !(location.state && location.state.mastodonModalOpen); - } + handleLoadMore = debounce(() => { + this.props.dispatch(expandFollowRequests()); + }, 300, { leading: true }); render () { - const { intl, accountIds } = this.props; + const { intl, accountIds, hasMore, multiColumn } = this.props; if (!accountIds) { return ( @@ -58,17 +53,23 @@ export default class FollowRequests extends ImmutablePureComponent { ); } + const emptyMessage = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />; + return ( - <Column name='follow-requests' icon='user-plus' heading={intl.formatMessage(messages.heading)}> + <Column bindToDocument={!multiColumn} name='follow-requests' icon='user-plus' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> - <ScrollContainer scrollKey='follow_requests' shouldUpdateScroll={this.shouldUpdateScroll}> - <div className='scrollable' onScroll={this.handleScroll}> - {accountIds.map(id => - <AccountAuthorizeContainer key={id} id={id} /> - )} - </div> - </ScrollContainer> + <ScrollableList + scrollKey='follow_requests' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountAuthorizeContainer key={id} id={id} /> + )} + </ScrollableList> </Column> ); } diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js index 2e47ab9b9..c78dcc8e4 100644 --- a/app/javascript/flavours/glitch/features/followers/index.js +++ b/app/javascript/flavours/glitch/features/followers/index.js @@ -2,20 +2,21 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; import { fetchAccount, fetchFollowers, expandFollowers, } from 'flavours/glitch/actions/accounts'; -import { ScrollContainer } from 'react-router-scroll-4'; +import { FormattedMessage } from 'react-intl'; import AccountContainer from 'flavours/glitch/containers/account_container'; import Column from 'flavours/glitch/features/ui/components/column'; import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container'; -import LoadMore from 'flavours/glitch/components/load_more'; import ImmutablePureComponent from 'react-immutable-pure-component'; import MissingIndicator from 'flavours/glitch/components/missing_indicator'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; const mapStateToProps = (state, props) => ({ isAccount: !!state.getIn(['accounts', props.params.accountId]), @@ -23,8 +24,8 @@ const mapStateToProps = (state, props) => ({ hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']), }); -@connect(mapStateToProps) -export default class Followers extends ImmutablePureComponent { +export default @connect(mapStateToProps) +class Followers extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, @@ -32,11 +33,14 @@ export default class Followers extends ImmutablePureComponent { accountIds: ImmutablePropTypes.list, hasMore: PropTypes.bool, isAccount: PropTypes.bool, + multiColumn: PropTypes.bool, }; componentWillMount () { - this.props.dispatch(fetchAccount(this.props.params.accountId)); - this.props.dispatch(fetchFollowers(this.props.params.accountId)); + if (!this.props.accountIds) { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(fetchFollowers(this.props.params.accountId)); + } } componentWillReceiveProps (nextProps) { @@ -58,22 +62,16 @@ export default class Followers extends ImmutablePureComponent { } } - handleLoadMore = (e) => { - e.preventDefault(); + handleLoadMore = debounce(() => { this.props.dispatch(expandFollowers(this.props.params.accountId)); - } - - shouldUpdateScroll = (prevRouterProps, { location }) => { - if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; - return !(location.state && location.state.mastodonModalOpen); - } + }, 300, { leading: true }); setRef = c => { this.column = c; } render () { - const { accountIds, hasMore, isAccount } = this.props; + const { accountIds, hasMore, isAccount, multiColumn } = this.props; if (!isAccount) { return ( @@ -83,8 +81,6 @@ export default class Followers extends ImmutablePureComponent { ); } - let loadMore = null; - if (!accountIds) { return ( <Column> @@ -93,23 +89,25 @@ export default class Followers extends ImmutablePureComponent { ); } - if (hasMore) { - loadMore = <LoadMore onClick={this.handleLoadMore} />; - } + const emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />; return ( <Column ref={this.setRef}> - <ProfileColumnHeader onClick={this.handleHeaderClick} /> - - <ScrollContainer scrollKey='followers' shouldUpdateScroll={this.shouldUpdateScroll}> - <div className='scrollable' onScroll={this.handleScroll}> - <div className='followers'> - <HeaderContainer accountId={this.props.params.accountId} hideTabs /> - {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} - {loadMore} - </div> - </div> - </ScrollContainer> + <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> + + <ScrollableList + scrollKey='followers' + hasMore={hasMore} + onLoadMore={this.handleLoadMore} + prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} + alwaysPrepend + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} withNote={false} /> + )} + </ScrollableList> </Column> ); } diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js index ad1445f3a..df7c19c22 100644 --- a/app/javascript/flavours/glitch/features/following/index.js +++ b/app/javascript/flavours/glitch/features/following/index.js @@ -2,20 +2,21 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; import { fetchAccount, fetchFollowing, expandFollowing, } from 'flavours/glitch/actions/accounts'; -import { ScrollContainer } from 'react-router-scroll-4'; +import { FormattedMessage } from 'react-intl'; import AccountContainer from 'flavours/glitch/containers/account_container'; import Column from 'flavours/glitch/features/ui/components/column'; import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container'; -import LoadMore from 'flavours/glitch/components/load_more'; import ImmutablePureComponent from 'react-immutable-pure-component'; import MissingIndicator from 'flavours/glitch/components/missing_indicator'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; const mapStateToProps = (state, props) => ({ isAccount: !!state.getIn(['accounts', props.params.accountId]), @@ -23,8 +24,8 @@ const mapStateToProps = (state, props) => ({ hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']), }); -@connect(mapStateToProps) -export default class Following extends ImmutablePureComponent { +export default @connect(mapStateToProps) +class Following extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, @@ -32,11 +33,14 @@ export default class Following extends ImmutablePureComponent { accountIds: ImmutablePropTypes.list, hasMore: PropTypes.bool, isAccount: PropTypes.bool, + multiColumn: PropTypes.bool, }; componentWillMount () { - this.props.dispatch(fetchAccount(this.props.params.accountId)); - this.props.dispatch(fetchFollowing(this.props.params.accountId)); + if (!this.props.accountIds) { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(fetchFollowing(this.props.params.accountId)); + } } componentWillReceiveProps (nextProps) { @@ -58,22 +62,16 @@ export default class Following extends ImmutablePureComponent { } } - handleLoadMore = (e) => { - e.preventDefault(); + handleLoadMore = debounce(() => { this.props.dispatch(expandFollowing(this.props.params.accountId)); - } - - shouldUpdateScroll = (prevRouterProps, { location }) => { - if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; - return !(location.state && location.state.mastodonModalOpen); - } + }, 300, { leading: true }); setRef = c => { this.column = c; } render () { - const { accountIds, hasMore, isAccount } = this.props; + const { accountIds, hasMore, isAccount, multiColumn } = this.props; if (!isAccount) { return ( @@ -83,8 +81,6 @@ export default class Following extends ImmutablePureComponent { ); } - let loadMore = null; - if (!accountIds) { return ( <Column> @@ -93,23 +89,25 @@ export default class Following extends ImmutablePureComponent { ); } - if (hasMore) { - loadMore = <LoadMore onClick={this.handleLoadMore} />; - } + const emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />; return ( <Column ref={this.setRef}> - <ProfileColumnHeader onClick={this.handleHeaderClick} /> - - <ScrollContainer scrollKey='following' shouldUpdateScroll={this.shouldUpdateScroll}> - <div className='scrollable' onScroll={this.handleScroll}> - <div className='following'> - <HeaderContainer accountId={this.props.params.accountId} hideTabs /> - {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} - {loadMore} - </div> - </div> - </ScrollContainer> + <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> + + <ScrollableList + scrollKey='following' + hasMore={hasMore} + onLoadMore={this.handleLoadMore} + prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} + alwaysPrepend + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} withNote={false} /> + )} + </ScrollableList> </Column> ); } diff --git a/app/javascript/flavours/glitch/features/generic_not_found/index.js b/app/javascript/flavours/glitch/features/generic_not_found/index.js index d01a1ba47..4412adaed 100644 --- a/app/javascript/flavours/glitch/features/generic_not_found/index.js +++ b/app/javascript/flavours/glitch/features/generic_not_found/index.js @@ -4,7 +4,7 @@ import MissingIndicator from 'flavours/glitch/components/missing_indicator'; const GenericNotFound = () => ( <Column> - <MissingIndicator /> + <MissingIndicator fullPage /> </Column> ); diff --git a/app/javascript/flavours/glitch/features/getting_started/components/trends.js b/app/javascript/flavours/glitch/features/getting_started/components/trends.js new file mode 100644 index 000000000..0734ec72b --- /dev/null +++ b/app/javascript/flavours/glitch/features/getting_started/components/trends.js @@ -0,0 +1,46 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Hashtag from 'flavours/glitch/components/hashtag'; +import { FormattedMessage } from 'react-intl'; + +export default class Trends extends ImmutablePureComponent { + + static defaultProps = { + loading: false, + }; + + static propTypes = { + trends: ImmutablePropTypes.list, + fetchTrends: PropTypes.func.isRequired, + }; + + componentDidMount () { + this.props.fetchTrends(); + this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000); + } + + componentWillUnmount () { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + } + + render () { + const { trends } = this.props; + + if (!trends || trends.isEmpty()) { + return null; + } + + return ( + <div className='getting-started__trends'> + <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4> + + {trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js new file mode 100644 index 000000000..1df3fb4fe --- /dev/null +++ b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { fetchTrends } from '../../../actions/trends'; +import Trends from '../components/trends'; + +const mapStateToProps = state => ({ + trends: state.getIn(['trends', 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + fetchTrends: () => dispatch(fetchTrends()), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Trends); diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js index f669220e3..d8a51c689 100644 --- a/app/javascript/flavours/glitch/features/getting_started/index.js +++ b/app/javascript/flavours/glitch/features/getting_started/index.js @@ -8,13 +8,15 @@ import { openModal } from 'flavours/glitch/actions/modal'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { me } from 'flavours/glitch/util/initial_state'; +import { me, profile_directory, showTrends } from 'flavours/glitch/util/initial_state'; import { fetchFollowRequests } from 'flavours/glitch/actions/accounts'; import { List as ImmutableList } from 'immutable'; import { createSelector } from 'reselect'; import { fetchLists } from 'flavours/glitch/actions/lists'; -import { preferencesLink, signOutLink } from 'flavours/glitch/util/backend_links'; +import { preferencesLink } from 'flavours/glitch/util/backend_links'; +import NavigationBar from '../compose/components/navigation_bar'; import LinkFooter from 'flavours/glitch/features/ui/components/link_footer'; +import TrendsContainer from './containers/trends_container'; const messages = defineMessages({ heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, @@ -29,13 +31,13 @@ const messages = defineMessages({ preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, - sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, lists_subheading: { id: 'column_subheading.lists', defaultMessage: 'Lists' }, misc: { id: 'navigation_bar.misc', defaultMessage: 'Misc' }, menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + profile_directory: { id: 'getting_started.directory', defaultMessage: 'Profile directory' }, }); const makeMapStateToProps = () => { @@ -102,16 +104,14 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2); } componentDidMount () { - const { myAccount, fetchFollowRequests, multiColumn } = this.props; + const { fetchFollowRequests, multiColumn } = this.props; if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) { this.context.router.history.replace('/timelines/home'); return; } - if (myAccount.get('locked')) { - fetchFollowRequests(); - } + fetchFollowRequests(); } render () { @@ -146,37 +146,43 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2); navItems.push(<ColumnLink key='5' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />); } - if (myAccount.get('locked')) { + if (myAccount.get('locked') || unreadFollowRequests > 0) { navItems.push(<ColumnLink key='6' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />); } - navItems.push(<ColumnLink key='7' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />); + if (profile_directory) { + navItems.push(<ColumnLink key='7' icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />); + } + + navItems.push(<ColumnLink key='8' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />); listItems = listItems.concat([ - <div key='8'> - <ColumnLink key='9' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' /> + <div key='9'> + <ColumnLink key='10' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' /> {lists.map(list => - <ColumnLink key={(9 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} /> + <ColumnLink key={(11 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} /> )} </div>, ]); return ( - <Column name='getting-started' icon='asterisk' heading={intl.formatMessage(messages.heading)} label={intl.formatMessage(messages.menu)} hideHeadingOnMobile> + <Column bindToDocument={!multiColumn} name='getting-started' icon='asterisk' heading={intl.formatMessage(messages.heading)} label={intl.formatMessage(messages.menu)} hideHeadingOnMobile> <div className='scrollable optionally-scrollable'> <div className='getting-started__wrapper'> - <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} /> + {!multiColumn && <NavigationBar account={myAccount} />} + {multiColumn && <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />} {navItems} <ColumnSubheading text={intl.formatMessage(messages.lists_subheading)} /> {listItems} <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} /> { preferencesLink !== undefined && <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href={preferencesLink} /> } <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={openSettings} /> - <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href={signOutLink} method='delete' /> </div> <LinkFooter /> </div> + + {multiColumn && showTrends && <TrendsContainer />} </Column> ); } diff --git a/app/javascript/flavours/glitch/features/getting_started_misc/index.js b/app/javascript/flavours/glitch/features/getting_started_misc/index.js index ee4452472..570fe78bf 100644 --- a/app/javascript/flavours/glitch/features/getting_started_misc/index.js +++ b/app/javascript/flavours/glitch/features/getting_started_misc/index.js @@ -24,9 +24,9 @@ const messages = defineMessages({ featured_users: { id: 'navigation_bar.featured_users', defaultMessage: 'Featured users' }, }); -@connect() +export default @connect() @injectIntl -export default class gettingStartedMisc extends ImmutablePureComponent { +class gettingStartedMisc extends ImmutablePureComponent { static propTypes = { intl: PropTypes.object.isRequired, diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js index dc0ffee85..9c39b158a 100644 --- a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js @@ -3,15 +3,15 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Toggle from 'react-toggle'; -import AsyncSelect from 'react-select/lib/Async'; +import AsyncSelect from 'react-select/async'; const messages = defineMessages({ placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' }, noOptions: { id: 'hashtag.column_settings.select.no_options_message', defaultMessage: 'No suggestions found' }, }); -@injectIntl -export default class ColumnSettings extends React.PureComponent { +export default @injectIntl +class ColumnSettings extends React.PureComponent { static propTypes = { settings: ImmutablePropTypes.map.isRequired, diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js index 757cd48fb..de1db692d 100644 --- a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js @@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({ }, onLoad (value) { - return api().get('/api/v2/search', { params: { q: value } }).then(response => { + return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => { return (response.data.hashtags || []).map((tag) => { return { value: tag.name, label: `#${tag.name}` }; }); diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js index 21efaceea..16dd80c4f 100644 --- a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js @@ -15,8 +15,8 @@ const mapStateToProps = (state, props) => ({ hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0, }); -@connect(mapStateToProps) -export default class HashtagTimeline extends React.PureComponent { +export default @connect(mapStateToProps) +class HashtagTimeline extends React.PureComponent { disconnects = []; @@ -145,6 +145,7 @@ export default class HashtagTimeline extends React.PureComponent { pinned={pinned} multiColumn={multiColumn} showBackButton + bindToDocument={!multiColumn} > {columnId && <ColumnSettingsContainer columnId={columnId} />} </ColumnHeader> @@ -155,6 +156,7 @@ export default class HashtagTimeline extends React.PureComponent { timelineId={`hashtag:${id}`} onLoadMore={this.handleLoadMore} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js index d7692513e..df615db65 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js +++ b/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js @@ -10,8 +10,8 @@ const messages = defineMessages({ settings: { id: 'home.settings', defaultMessage: 'Column settings' }, }); -@injectIntl -export default class ColumnSettings extends React.PureComponent { +export default @injectIntl +class ColumnSettings extends React.PureComponent { static propTypes = { settings: ImmutablePropTypes.map.isRequired, diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.js b/app/javascript/flavours/glitch/features/home_timeline/index.js index 8eb79fa60..9b71a4404 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/index.js +++ b/app/javascript/flavours/glitch/features/home_timeline/index.js @@ -19,9 +19,9 @@ const mapStateToProps = state => ({ isPartial: state.getIn(['timelines', 'home', 'isPartial']), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class HomeTimeline extends React.PureComponent { +class HomeTimeline extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, @@ -97,7 +97,7 @@ export default class HomeTimeline extends React.PureComponent { const pinned = !!columnId; return ( - <Column ref={this.setRef} name='home' label={intl.formatMessage(messages.title)}> + <Column bindToDocument={!multiColumn} ref={this.setRef} name='home' label={intl.formatMessage(messages.title)}> <ColumnHeader icon='home' active={hasUnread} @@ -117,6 +117,7 @@ export default class HomeTimeline extends React.PureComponent { onLoadMore={this.handleLoadMore} timelineId='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Visit {public} or use search to get started and meet other users.' values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js index f7b475f8d..bc7571200 100644 --- a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js +++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js @@ -14,9 +14,9 @@ const mapStateToProps = state => ({ collapseEnabled: state.getIn(['local_settings', 'collapsed', 'enabled']), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class KeyboardShortcuts extends ImmutablePureComponent { +class KeyboardShortcuts extends ImmutablePureComponent { static propTypes = { intl: PropTypes.object.isRequired, @@ -25,10 +25,10 @@ export default class KeyboardShortcuts extends ImmutablePureComponent { }; render () { - const { intl, collapseEnabled } = this.props; + const { intl, collapseEnabled, multiColumn } = this.props; return ( - <Column icon='question' heading={intl.formatMessage(messages.heading)}> + <Column bindToDocument={!multiColumn} icon='question' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> <div className='keyboard-shortcuts scrollable optionally-scrollable'> <table> @@ -68,6 +68,10 @@ export default class KeyboardShortcuts extends ImmutablePureComponent { <td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td> </tr> <tr> + <td><kbd>e</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.open_media' defaultMessage='to open media' /></td> + </tr> + <tr> <td><kbd>x</kbd></td> <td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td> </tr> diff --git a/app/javascript/flavours/glitch/features/list_adder/components/list.js b/app/javascript/flavours/glitch/features/list_adder/components/list.js index cb8eb7d7a..4666ca47b 100644 --- a/app/javascript/flavours/glitch/features/list_adder/components/list.js +++ b/app/javascript/flavours/glitch/features/list_adder/components/list.js @@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import IconButton from '../../../components/icon_button'; import { defineMessages, injectIntl } from 'react-intl'; import { removeFromListAdder, addToListAdder } from '../../../actions/lists'; +import Icon from 'flavours/glitch/components/icon'; const messages = defineMessages({ remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' }, @@ -53,7 +54,7 @@ class List extends ImmutablePureComponent { <div className='list'> <div className='list__wrapper'> <div className='list__display-name'> - <i className='fa fa-fw fa-list-ul column-link__icon' /> + <Icon id='list-ul' className='column-link__icon' fixedWidth /> {list.get('title')} </div> diff --git a/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js b/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js index 24aaf82ac..a8cab2762 100644 --- a/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js +++ b/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js @@ -11,7 +11,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ value: state.getIn(['listEditor', 'title']), - disabled: !state.getIn(['listEditor', 'isChanged']), + disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']), }); const mapDispatchToProps = dispatch => ({ @@ -19,9 +19,9 @@ const mapDispatchToProps = dispatch => ({ onSubmit: () => dispatch(submitListEditor(false)), }); -@connect(mapStateToProps, mapDispatchToProps) +export default @connect(mapStateToProps, mapDispatchToProps) @injectIntl -export default class ListForm extends React.PureComponent { +class ListForm extends React.PureComponent { static propTypes = { value: PropTypes.string.isRequired, diff --git a/app/javascript/flavours/glitch/features/list_editor/components/search.js b/app/javascript/flavours/glitch/features/list_editor/components/search.js index 280632652..192643f77 100644 --- a/app/javascript/flavours/glitch/features/list_editor/components/search.js +++ b/app/javascript/flavours/glitch/features/list_editor/components/search.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages } from 'react-intl'; import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; const messages = defineMessages({ search: { id: 'lists.search', defaultMessage: 'Search among people you follow' }, @@ -51,8 +52,8 @@ export default class Search extends React.PureComponent { </label> <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}> - <i className={classNames('fa fa-search', { active: !hasValue })} /> - <i aria-label={intl.formatMessage(messages.search)} className={classNames('fa fa-times-circle', { active: hasValue })} /> + <Icon id='search' className={classNames({ active: !hasValue })} /> + <Icon id='times-circle' aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} /> </div> </div> ); diff --git a/app/javascript/flavours/glitch/features/list_editor/index.js b/app/javascript/flavours/glitch/features/list_editor/index.js index 5f552b113..75b0de3d3 100644 --- a/app/javascript/flavours/glitch/features/list_editor/index.js +++ b/app/javascript/flavours/glitch/features/list_editor/index.js @@ -22,9 +22,9 @@ const mapDispatchToProps = dispatch => ({ onReset: () => dispatch(resetListEditor()), }); -@connect(mapStateToProps, mapDispatchToProps) +export default @connect(mapStateToProps, mapDispatchToProps) @injectIntl -export default class ListEditor extends ImmutablePureComponent { +class ListEditor extends ImmutablePureComponent { static propTypes = { listId: PropTypes.string.isRequired, diff --git a/app/javascript/flavours/glitch/features/list_timeline/index.js b/app/javascript/flavours/glitch/features/list_timeline/index.js index 0405073c5..908a65597 100644 --- a/app/javascript/flavours/glitch/features/list_timeline/index.js +++ b/app/javascript/flavours/glitch/features/list_timeline/index.js @@ -13,6 +13,7 @@ import { fetchList, deleteList, updateList } from 'flavours/glitch/actions/lists import { openModal } from 'flavours/glitch/actions/modal'; import MissingIndicator from 'flavours/glitch/components/missing_indicator'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import Icon from 'flavours/glitch/components/icon'; const messages = defineMessages({ deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' }, @@ -27,9 +28,9 @@ const mapStateToProps = (state, props) => ({ hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0, }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class ListTimeline extends React.PureComponent { +class ListTimeline extends React.PureComponent { static contextTypes = { router: PropTypes.object, @@ -173,14 +174,15 @@ export default class ListTimeline extends React.PureComponent { onClick={this.handleHeaderClick} pinned={pinned} multiColumn={multiColumn} + bindToDocument={!multiColumn} > <div className='column-header__links'> <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleEditClick}> - <i className='fa fa-pencil' /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' /> + <Icon id='pencil' /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' /> </button> <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleDeleteClick}> - <i className='fa fa-trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' /> + <Icon id='trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' /> </button> </div> @@ -211,6 +213,7 @@ export default class ListTimeline extends React.PureComponent { timelineId={`list:${id}`} onLoadMore={this.handleLoadMore} emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet.' />} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/flavours/glitch/features/lists/components/new_list_form.js b/app/javascript/flavours/glitch/features/lists/components/new_list_form.js index 61fcbeaf9..cc78d30b7 100644 --- a/app/javascript/flavours/glitch/features/lists/components/new_list_form.js +++ b/app/javascript/flavours/glitch/features/lists/components/new_list_form.js @@ -20,9 +20,9 @@ const mapDispatchToProps = dispatch => ({ onSubmit: () => dispatch(submitListEditor(true)), }); -@connect(mapStateToProps, mapDispatchToProps) +export default @connect(mapStateToProps, mapDispatchToProps) @injectIntl -export default class NewListForm extends React.PureComponent { +class NewListForm extends React.PureComponent { static propTypes = { value: PropTypes.string.isRequired, @@ -66,7 +66,7 @@ export default class NewListForm extends React.PureComponent { </label> <IconButton - disabled={disabled} + disabled={disabled || !value} icon='plus' title={title} onClick={this.handleClick} diff --git a/app/javascript/flavours/glitch/features/lists/index.js b/app/javascript/flavours/glitch/features/lists/index.js index 8b0470c92..adde3dd5c 100644 --- a/app/javascript/flavours/glitch/features/lists/index.js +++ b/app/javascript/flavours/glitch/features/lists/index.js @@ -6,12 +6,13 @@ import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; import Column from 'flavours/glitch/features/ui/components/column'; import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; import { fetchLists } from 'flavours/glitch/actions/lists'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import ColumnLink from 'flavours/glitch/features/ui/components/column_link'; import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading'; import NewListForm from './components/new_list_form'; import { createSelector } from 'reselect'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; const messages = defineMessages({ heading: { id: 'column.lists', defaultMessage: 'Lists' }, @@ -30,15 +31,16 @@ const mapStateToProps = state => ({ lists: getOrderedLists(state), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class Lists extends ImmutablePureComponent { +class Lists extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, lists: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -46,7 +48,7 @@ export default class Lists extends ImmutablePureComponent { } render () { - const { intl, lists } = this.props; + const { intl, lists, multiColumn } = this.props; if (!lists) { return ( @@ -56,19 +58,24 @@ export default class Lists extends ImmutablePureComponent { ); } + const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />; + return ( - <Column icon='bars' heading={intl.formatMessage(messages.heading)}> + <Column bindToDocument={!multiColumn} icon='bars' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> <NewListForm /> - <div className='scrollable'> - <ColumnSubheading text={intl.formatMessage(messages.subheading)} /> - + <ColumnSubheading text={intl.formatMessage(messages.subheading)} /> + <ScrollableList + scrollKey='lists' + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > {lists.map(list => <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} /> )} - </div> + </ScrollableList> </Column> ); } diff --git a/app/javascript/flavours/glitch/features/local_settings/navigation/index.js b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js index c583c4863..ab3a554bf 100644 --- a/app/javascript/flavours/glitch/features/local_settings/navigation/index.js +++ b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js @@ -13,14 +13,15 @@ const messages = defineMessages({ general: { id: 'settings.general', defaultMessage: 'General' }, compose: { id: 'settings.compose_box_opts', defaultMessage: 'Compose box' }, content_warnings: { id: 'settings.content_warnings', defaultMessage: 'Content Warnings' }, + filters: { id: 'settings.filters', defaultMessage: 'Filters' }, collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' }, media: { id: 'settings.media', defaultMessage: 'Media' }, preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' }, close: { id: 'settings.close', defaultMessage: 'Close' }, }); -@injectIntl -export default class LocalSettingsNavigation extends React.PureComponent { +export default @injectIntl +class LocalSettingsNavigation extends React.PureComponent { static propTypes = { index : PropTypes.number, @@ -60,27 +61,34 @@ export default class LocalSettingsNavigation extends React.PureComponent { active={index === 3} index={3} onNavigate={onNavigate} - icon='angle-double-up' - title={intl.formatMessage(messages.collapsed)} + icon='filter' + title={intl.formatMessage(messages.filters)} /> <LocalSettingsNavigationItem active={index === 4} index={4} onNavigate={onNavigate} + icon='angle-double-up' + title={intl.formatMessage(messages.collapsed)} + /> + <LocalSettingsNavigationItem + active={index === 5} + index={5} + onNavigate={onNavigate} icon='image' title={intl.formatMessage(messages.media)} /> <LocalSettingsNavigationItem - active={index === 5} + active={index === 6} href={ preferencesLink } - index={5} - icon='sliders' + index={6} + icon='cog' title={intl.formatMessage(messages.preferences)} /> <LocalSettingsNavigationItem - active={index === 6} + active={index === 7} className='close' - index={6} + index={7} onNavigate={onClose} icon='times' title={intl.formatMessage(messages.close)} diff --git a/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js b/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js index 68a998b6c..4dec7d154 100644 --- a/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js +++ b/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js @@ -3,6 +3,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; + // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * export default class LocalSettingsPage extends React.PureComponent { @@ -42,7 +44,7 @@ export default class LocalSettingsPage extends React.PureComponent { active, }, className); - const iconElem = icon ? <i className={`fa fa-fw fa-${icon}`} /> : (textIcon ? <span className='text-icon-button'>{textIcon}</span> : null); + const iconElem = icon ? <Icon fixedWidth id={icon} /> : (textIcon ? <span className='text-icon-button'>{textIcon}</span> : null); if (href) return ( <a diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js index 23499455b..e08c12c76 100644 --- a/app/javascript/flavours/glitch/features/local_settings/page/index.js +++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js @@ -21,10 +21,17 @@ const messages = defineMessages({ side_arm_copy: { id: 'settings.side_arm_reply_mode.copy', defaultMessage: 'Copy privacy setting of the toot being replied to' }, side_arm_restrict: { id: 'settings.side_arm_reply_mode.restrict', defaultMessage: 'Restrict privacy setting to that of the toot being replied to' }, regexp: { id: 'settings.content_warnings.regexp', defaultMessage: 'Regular expression' }, + filters_drop: { id: 'settings.filtering_behavior.drop', defaultMessage: 'Hide filtered toots completely' }, + filters_upstream: { id: 'settings.filtering_behavior.upstream', defaultMessage: 'Show "filtered" like vanilla Mastodon' }, + filters_hide: { id: 'settings.filtering_behavior.hide', defaultMessage: 'Show "filtered" and add a button to display why' }, + filters_cw: { id: 'settings.filtering_behavior.cw', defaultMessage: 'Still display the post, and add filtered words to content warning' }, + rewrite_mentions_no: { id: 'settings.rewrite_mentions_no', defaultMessage: 'Do not rewrite mentions' }, + rewrite_mentions_acct: { id: 'settings.rewrite_mentions_acct', defaultMessage: 'Rewrite with username and domain (when the account is remote)' }, + rewrite_mentions_username: { id: 'settings.rewrite_mentions_username', defaultMessage: 'Rewrite with username' }, }); -@injectIntl -export default class LocalSettingsPage extends React.PureComponent { +export default @injectIntl +class LocalSettingsPage extends React.PureComponent { static propTypes = { index : PropTypes.number, @@ -62,6 +69,28 @@ export default class LocalSettingsPage extends React.PureComponent { > <FormattedMessage id='settings.confirm_boost_missing_media_description' defaultMessage='Show confirmation dialog before boosting toots lacking media descriptions' /> </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['tag_misleading_links']} + id='mastodon-settings--tag_misleading_links' + onChange={onChange} + > + <FormattedMessage id='settings.tag_misleading_links' defaultMessage='Tag misleading links' /> + <span className='hint'><FormattedMessage id='settings.tag_misleading_links.hint' defaultMessage="Add a visual indication with the link target host to every link not mentioning it explicitly" /></span> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['rewrite_mentions']} + id='mastodon-settings--rewrite_mentions' + options={[ + { value: 'no', message: intl.formatMessage(messages.rewrite_mentions_no) }, + { value: 'acct', message: intl.formatMessage(messages.rewrite_mentions_acct) }, + { value: 'username', message: intl.formatMessage(messages.rewrite_mentions_username) }, + ]} + onChange={onChange} + > + <FormattedMessage id='settings.rewrite_mentions' defaultMessage='Rewrite mentions in displayed statuses' /> + </LocalSettingsPageItem> <section> <h2><FormattedMessage id='settings.notifications_opts' defaultMessage='Notifications options' /></h2> <LocalSettingsPageItem @@ -223,6 +252,25 @@ export default class LocalSettingsPage extends React.PureComponent { </LocalSettingsPageItem> </div> ), + ({ intl, onChange, settings }) => ( + <div className='glitch local-settings__page filters'> + <h1><FormattedMessage id='settings.filters' defaultMessage='Filters' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['filtering_behavior']} + id='mastodon-settings--filters-behavior' + onChange={onChange} + options={[ + { value: 'drop', message: intl.formatMessage(messages.filters_drop) }, + { value: 'upstream', message: intl.formatMessage(messages.filters_upstream) }, + { value: 'hide', message: intl.formatMessage(messages.filters_hide) }, + { value: 'content_warning', message: intl.formatMessage(messages.filters_cw) } + ]} + > + <FormattedMessage id='settings.filtering_behavior' defaultMessage='Filtering behavior' /> + </LocalSettingsPageItem> + </div> + ), ({ onChange, settings }) => ( <div className='glitch local-settings__page collapsed'> <h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1> diff --git a/app/javascript/flavours/glitch/features/local_settings/page/item/index.js b/app/javascript/flavours/glitch/features/local_settings/page/item/index.js index 66b937365..5a68523f6 100644 --- a/app/javascript/flavours/glitch/features/local_settings/page/item/index.js +++ b/app/javascript/flavours/glitch/features/local_settings/page/item/index.js @@ -8,7 +8,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; export default class LocalSettingsPageItem extends React.PureComponent { static propTypes = { - children: PropTypes.element.isRequired, + children: PropTypes.node.isRequired, dependsOn: PropTypes.array, dependsOnNot: PropTypes.array, id: PropTypes.string.isRequired, @@ -63,12 +63,12 @@ export default class LocalSettingsPageItem extends React.PureComponent { disabled={!enabled} /> {opt.message} - {opt.hint && <span class='hint'>{opt.hint}</span>} + {opt.hint && <span className='hint'>{opt.hint}</span>} </label> ); }); return ( - <div class='glitch local-settings__page__item radio_buttons'> + <div className='glitch local-settings__page__item radio_buttons'> <fieldset> <legend>{children}</legend> {optionElems} diff --git a/app/javascript/flavours/glitch/features/mutes/index.js b/app/javascript/flavours/glitch/features/mutes/index.js index bbcbea701..c27a530d5 100644 --- a/app/javascript/flavours/glitch/features/mutes/index.js +++ b/app/javascript/flavours/glitch/features/mutes/index.js @@ -1,15 +1,16 @@ import React from 'react'; import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; -import { ScrollContainer } from 'react-router-scroll-4'; import Column from 'flavours/glitch/features/ui/components/column'; import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; import AccountContainer from 'flavours/glitch/containers/account_container'; import { fetchMutes, expandMutes } from 'flavours/glitch/actions/mutes'; -import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; const messages = defineMessages({ heading: { id: 'column.mutes', defaultMessage: 'Muted users' }, @@ -17,38 +18,32 @@ const messages = defineMessages({ const mapStateToProps = state => ({ accountIds: state.getIn(['user_lists', 'mutes', 'items']), + hasMore: !!state.getIn(['user_lists', 'mutes', 'next']), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class Mutes extends ImmutablePureComponent { +class Mutes extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, + hasMore: PropTypes.bool, accountIds: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, }; componentWillMount () { this.props.dispatch(fetchMutes()); } - handleScroll = (e) => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - - if (scrollTop === scrollHeight - clientHeight) { - this.props.dispatch(expandMutes()); - } - } - - shouldUpdateScroll = (prevRouterProps, { location }) => { - if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; - return !(location.state && location.state.mastodonModalOpen); - } + handleLoadMore = debounce(() => { + this.props.dispatch(expandMutes()); + }, 300, { leading: true }); render () { - const { intl, accountIds } = this.props; + const { intl, accountIds, hasMore, multiColumn } = this.props; if (!accountIds) { return ( @@ -58,16 +53,22 @@ export default class Mutes extends ImmutablePureComponent { ); } + const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />; + return ( - <Column name='mutes' icon='volume-off' heading={intl.formatMessage(messages.heading)}> + <Column bindToDocument={!multiColumn} name='mutes' icon='volume-off' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> - <ScrollContainer scrollKey='mutes' shouldUpdateScroll={this.shouldUpdateScroll}> - <div className='scrollable mutes' onScroll={this.handleScroll}> - {accountIds.map(id => - <AccountContainer key={id} id={id} /> - )} - </div> - </ScrollContainer> + <ScrollableList + scrollKey='mutes' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} /> + )} + </ScrollableList> </Column> ); } diff --git a/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js index 22a10753f..ee77cfb8e 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js +++ b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; export default class ClearColumnButton extends React.Component { @@ -10,7 +11,7 @@ export default class ClearColumnButton extends React.Component { render () { return ( - <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.props.onClick}><i className='fa fa-eraser' /> <FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' /></button> + <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.props.onClick}><Icon id='eraser' /> <FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' /></button> ); } diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js index e29bd61f5..e4d5d0eda 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js @@ -58,6 +58,17 @@ export default class ColumnSettings extends React.PureComponent { </div> </div> + <div role='group' aria-labelledby='notifications-follow-request'> + <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} /> + </div> + </div> + <div role='group' aria-labelledby='notifications-favourite'> <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> diff --git a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js index 3457b7598..6118305d6 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js +++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; const tooltips = defineMessages({ mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, @@ -63,35 +64,35 @@ class FilterBar extends React.PureComponent { onClick={this.onClick('mention')} title={intl.formatMessage(tooltips.mentions)} > - <i className='fa fa-fw fa-at' /> + <Icon id='reply-all' fixedWidth /> </button> <button className={selectedFilter === 'favourite' ? 'active' : ''} onClick={this.onClick('favourite')} title={intl.formatMessage(tooltips.favourites)} > - <i className='fa fa-fw fa-star' /> + <Icon id='star' fixedWidth /> </button> <button className={selectedFilter === 'reblog' ? 'active' : ''} onClick={this.onClick('reblog')} title={intl.formatMessage(tooltips.boosts)} > - <i className='fa fa-fw fa-retweet' /> + <Icon id='retweet' fixedWidth /> </button> <button className={selectedFilter === 'poll' ? 'active' : ''} onClick={this.onClick('poll')} title={intl.formatMessage(tooltips.polls)} > - <i className='fa fa-fw fa-tasks' /> + <Icon id='tasks' fixedWidth /> </button> <button className={selectedFilter === 'follow' ? 'active' : ''} onClick={this.onClick('follow')} title={intl.formatMessage(tooltips.follows)} > - <i className='fa fa-fw fa-user-plus' /> + <Icon id='user-plus' fixedWidth /> </button> </div> ); diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.js b/app/javascript/flavours/glitch/features/notifications/components/follow.js index ea81d9ab4..2b71f3107 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/follow.js +++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js @@ -10,6 +10,7 @@ import { HotKeys } from 'react-hotkeys'; import Permalink from 'flavours/glitch/components/permalink'; import AccountContainer from 'flavours/glitch/containers/account_container'; import NotificationOverlayContainer from '../containers/overlay_container'; +import Icon from 'flavours/glitch/components/icon'; export default class NotificationFollow extends ImmutablePureComponent { @@ -78,7 +79,7 @@ export default class NotificationFollow extends ImmutablePureComponent { <div className='notification notification-follow focusable' tabIndex='0'> <div className='notification__message'> <div className='notification__favourite-icon-wrapper'> - <i className='fa fa-fw fa-user-plus' /> + <Icon fixedWidth id='user-plus' /> </div> <FormattedMessage diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js new file mode 100644 index 000000000..d73dac434 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js @@ -0,0 +1,130 @@ +import React, { Fragment } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import Permalink from 'flavours/glitch/components/permalink'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import NotificationOverlayContainer from '../containers/overlay_container'; +import { HotKeys } from 'react-hotkeys'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, + reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, +}); + +export default @injectIntl +class FollowRequest extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onAuthorize: PropTypes.func.isRequired, + onReject: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + notification: ImmutablePropTypes.map.isRequired, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + } + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + } + + handleOpen = () => { + this.handleOpenProfile(); + } + + handleOpenProfile = () => { + const { notification } = this.props; + this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`); + } + + handleMention = e => { + e.preventDefault(); + + const { notification, onMention } = this.props; + onMention(notification.get('account'), this.context.router.history); + } + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + + render () { + const { intl, hidden, account, onAuthorize, onReject, notification } = this.props; + + if (!account) { + return <div />; + } + + if (hidden) { + return ( + <Fragment> + {account.get('display_name')} + {account.get('username')} + </Fragment> + ); + } + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + <bdi><Permalink + className='notification__display-name' + href={account.get('url')} + title={account.get('acct')} + to={`/accounts/${account.get('id')}`} + dangerouslySetInnerHTML={{ __html: displayName }} + /></bdi> + ); + + return ( + <HotKeys handlers={this.getHandlers()}> + <div className='notification notification-follow-request focusable' tabIndex='0'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <Icon id='user' fixedWidth /> + </div> + + <FormattedMessage + id='notification.follow_request' + defaultMessage='{name} has requested to follow you' + values={{ name: link }} + /> + </div> + + <div className='account'> + <div className='account__wrapper'> + <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + <DisplayName account={account} /> + </Permalink> + + <div className='account__relationship'> + <IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /> + <IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /> + </div> + </div> + </div> + + <NotificationOverlayContainer notification={notification} /> + </div> + </HotKeys> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js index 5c5bbf604..62fc28386 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/notification.js +++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js @@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; // Our imports, import StatusContainer from 'flavours/glitch/containers/status_container'; import NotificationFollow from './follow'; +import NotificationFollowRequestContainer from '../containers/follow_request_container'; export default class Notification extends ImmutablePureComponent { @@ -47,6 +48,18 @@ export default class Notification extends ImmutablePureComponent { onMention={onMention} /> ); + case 'follow_request': + return ( + <NotificationFollowRequestContainer + hidden={hidden} + id={notification.get('id')} + account={notification.get('account')} + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + /> + ); case 'mention': return ( <StatusContainer diff --git a/app/javascript/flavours/glitch/features/notifications/components/overlay.js b/app/javascript/flavours/glitch/features/notifications/components/overlay.js index e56f9c628..f3ccafc06 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/overlay.js +++ b/app/javascript/flavours/glitch/features/notifications/components/overlay.js @@ -9,13 +9,14 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { defineMessages, injectIntl } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; const messages = defineMessages({ markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' }, }); -@injectIntl -export default class NotificationOverlay extends ImmutablePureComponent { +export default @injectIntl +class NotificationOverlay extends ImmutablePureComponent { static propTypes = { notification : ImmutablePropTypes.map.isRequired, @@ -47,7 +48,7 @@ export default class NotificationOverlay extends ImmutablePureComponent { > <div className='wrappy'> <div className='ckbox' aria-hidden='true' title={label}> - {active ? (<i className='fa fa-check' />) : ''} + {active ? (<Icon id='check' />) : ''} </div> </div> </div> diff --git a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js index ac2211e48..0264b6815 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js +++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js @@ -12,6 +12,7 @@ export default class SettingToggle extends React.PureComponent { label: PropTypes.node.isRequired, meta: PropTypes.node, onChange: PropTypes.func.isRequired, + defaultValue: PropTypes.bool, } onChange = ({ target }) => { @@ -19,12 +20,12 @@ export default class SettingToggle extends React.PureComponent { } render () { - const { prefix, settings, settingPath, label, meta } = this.props; + const { prefix, settings, settingPath, label, meta, defaultValue } = this.props; const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); return ( <div className='setting-toggle'> - <Toggle id={id} checked={settings.getIn(settingPath)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> + <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> <label htmlFor={id} className='setting-toggle__label'>{label}</label> {meta && <span className='setting-meta__label'>{meta}</span>} </div> diff --git a/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js b/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js new file mode 100644 index 000000000..82357adfb --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import FollowRequest from '../components/follow_request'; +import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/glitch/actions/accounts'; + +const mapDispatchToProps = (dispatch, { account }) => ({ + onAuthorize () { + dispatch(authorizeFollowRequest(account.get('id'))); + }, + + onReject () { + dispatch(rejectFollowRequest(account.get('id'))); + }, +}); + +export default connect(null, mapDispatchToProps)(FollowRequest); diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js index f2a1ccc3b..7f06d70c5 100644 --- a/app/javascript/flavours/glitch/features/notifications/index.js +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -10,6 +10,7 @@ import { scrollTopNotifications, mountNotifications, unmountNotifications, + loadPending, } from 'flavours/glitch/actions/notifications'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import NotificationContainer from './containers/notification_container'; @@ -46,8 +47,9 @@ const mapStateToProps = state => ({ notifications: getNotifications(state), localSettings: state.get('local_settings'), isLoading: state.getIn(['notifications', 'isLoading'], true), - isUnread: state.getIn(['notifications', 'unread']) > 0, + isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0, hasMore: state.getIn(['notifications', 'hasMore']), + numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), }); @@ -65,9 +67,9 @@ const mapDispatchToProps = dispatch => ({ dispatch, }); -@connect(mapStateToProps, mapDispatchToProps) +export default @connect(mapStateToProps, mapDispatchToProps) @injectIntl -export default class Notifications extends React.PureComponent { +class Notifications extends React.PureComponent { static propTypes = { columnId: PropTypes.string, @@ -80,6 +82,7 @@ export default class Notifications extends React.PureComponent { isUnread: PropTypes.bool, multiColumn: PropTypes.bool, hasMore: PropTypes.bool, + numPending: PropTypes.number, localSettings: ImmutablePropTypes.map, notifCleaningActive: PropTypes.bool, onEnterCleaningMode: PropTypes.func, @@ -100,6 +103,10 @@ export default class Notifications extends React.PureComponent { this.props.dispatch(expandNotifications({ maxId: last && last.get('id') })); }, 300, { leading: true }); + handleLoadPending = () => { + this.props.dispatch(loadPending()); + }; + handleScrollToTop = debounce(() => { this.props.dispatch(scrollTopNotifications(true)); }, 100); @@ -170,7 +177,7 @@ export default class Notifications extends React.PureComponent { } render () { - const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, showFilterBar } = this.props; + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props; const pinned = !!columnId; const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; @@ -212,11 +219,14 @@ export default class Notifications extends React.PureComponent { isLoading={isLoading} showLoading={isLoading && notifications.size === 0} hasMore={hasMore} + numPending={numPending} emptyMessage={emptyMessage} onLoadMore={this.handleLoadOlder} + onLoadPending={this.handleLoadPending} onScrollToTop={this.handleScrollToTop} onScroll={this.handleScroll} shouldUpdateScroll={shouldUpdateScroll} + bindToDocument={!multiColumn} > {scrollableContent} </ScrollableList> @@ -224,6 +234,7 @@ export default class Notifications extends React.PureComponent { return ( <Column + bindToDocument={!multiColumn} ref={this.setColumnRef} name='notifications' extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null} diff --git a/app/javascript/flavours/glitch/features/pinned_accounts_editor/index.js b/app/javascript/flavours/glitch/features/pinned_accounts_editor/index.js index 7484e458e..5f03c7e93 100644 --- a/app/javascript/flavours/glitch/features/pinned_accounts_editor/index.js +++ b/app/javascript/flavours/glitch/features/pinned_accounts_editor/index.js @@ -21,9 +21,9 @@ const mapDispatchToProps = dispatch => ({ onReset: () => dispatch(resetPinnedAccountsEditor()), }); -@connect(mapStateToProps, mapDispatchToProps) +export default @connect(mapStateToProps, mapDispatchToProps) @injectIntl -export default class PinnedAccountsEditor extends ImmutablePureComponent { +class PinnedAccountsEditor extends ImmutablePureComponent { static propTypes = { onClose: PropTypes.func.isRequired, diff --git a/app/javascript/flavours/glitch/features/pinned_statuses/index.js b/app/javascript/flavours/glitch/features/pinned_statuses/index.js index f56d70176..34d8e465f 100644 --- a/app/javascript/flavours/glitch/features/pinned_statuses/index.js +++ b/app/javascript/flavours/glitch/features/pinned_statuses/index.js @@ -18,15 +18,16 @@ const mapStateToProps = state => ({ hasMore: !!state.getIn(['status_lists', 'pins', 'next']), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class PinnedStatuses extends ImmutablePureComponent { +class PinnedStatuses extends ImmutablePureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, statusIds: ImmutablePropTypes.list.isRequired, intl: PropTypes.object.isRequired, hasMore: PropTypes.bool.isRequired, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -42,15 +43,16 @@ export default class PinnedStatuses extends ImmutablePureComponent { } render () { - const { intl, statusIds, hasMore } = this.props; + const { intl, statusIds, hasMore, multiColumn } = this.props; return ( - <Column icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}> + <Column bindToDocument={!multiColumn} icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}> <ColumnBackButtonSlim /> <StatusList statusIds={statusIds} scrollKey='pinned_statuses' hasMore={hasMore} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/flavours/glitch/features/public_timeline/index.js b/app/javascript/flavours/glitch/features/public_timeline/index.js index 7fe472202..4d139a326 100644 --- a/app/javascript/flavours/glitch/features/public_timeline/index.js +++ b/app/javascript/flavours/glitch/features/public_timeline/index.js @@ -14,20 +14,22 @@ const messages = defineMessages({ title: { id: 'column.public', defaultMessage: 'Federated timeline' }, }); -const mapStateToProps = (state, { onlyMedia, columnId }) => { +const mapStateToProps = (state, { columnId }) => { const uuid = columnId; const columns = state.getIn(['settings', 'columns']); const index = columns.findIndex(c => c.get('uuid') === uuid); + const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'public', 'other', 'onlyMedia']); + const timelineState = state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`]); return { - hasUnread: state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`, 'unread']) > 0, - onlyMedia: (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'public', 'other', 'onlyMedia']), + hasUnread: !!timelineState && timelineState.get('unread') > 0, + onlyMedia, }; }; -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class PublicTimeline extends React.PureComponent { +class PublicTimeline extends React.PureComponent { static defaultProps = { onlyMedia: false, @@ -99,16 +101,12 @@ export default class PublicTimeline extends React.PureComponent { dispatch(expandPublicTimeline({ maxId, onlyMedia })); } - shouldUpdateScroll = (prevRouterProps, { location }) => { - return !(location.state && location.state.mastodonModalOpen) - } - render () { const { intl, columnId, hasUnread, multiColumn, onlyMedia } = this.props; const pinned = !!columnId; return ( - <Column ref={this.setRef} name='federated' label={intl.formatMessage(messages.title)}> + <Column bindToDocument={!multiColumn} ref={this.setRef} name='federated' label={intl.formatMessage(messages.title)}> <ColumnHeader icon='globe' active={hasUnread} @@ -128,6 +126,7 @@ export default class PublicTimeline extends React.PureComponent { trackScroll={!pinned} scrollKey={`public_timeline-${columnId}`} emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/flavours/glitch/features/reblogs/index.js b/app/javascript/flavours/glitch/features/reblogs/index.js index e007506b7..258070358 100644 --- a/app/javascript/flavours/glitch/features/reblogs/index.js +++ b/app/javascript/flavours/glitch/features/reblogs/index.js @@ -4,34 +4,39 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; import { fetchReblogs } from 'flavours/glitch/actions/interactions'; -import { ScrollContainer } from 'react-router-scroll-4'; import AccountContainer from 'flavours/glitch/containers/account_container'; import Column from 'flavours/glitch/features/ui/components/column'; +import Icon from 'flavours/glitch/components/icon'; import ColumnHeader from 'flavours/glitch/components/column_header'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; const messages = defineMessages({ heading: { id: 'column.reblogged_by', defaultMessage: 'Boosted by' }, + refresh: { id: 'refresh', defaultMessage: 'Refresh' }, }); const mapStateToProps = (state, props) => ({ accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class Reblogs extends ImmutablePureComponent { +class Reblogs extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, accountIds: ImmutablePropTypes.list, + multiColumn: PropTypes.bool, intl: PropTypes.object.isRequired, }; componentWillMount () { - this.props.dispatch(fetchReblogs(this.props.params.statusId)); + if (!this.props.accountIds) { + this.props.dispatch(fetchReblogs(this.props.params.statusId)); + } } componentWillReceiveProps(nextProps) { @@ -40,11 +45,6 @@ export default class Reblogs extends ImmutablePureComponent { } } - shouldUpdateScroll = (prevRouterProps, { location }) => { - if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; - return !(location.state && location.state.mastodonModalOpen); - } - handleHeaderClick = () => { this.column.scrollTop(); } @@ -53,8 +53,12 @@ export default class Reblogs extends ImmutablePureComponent { this.column = c; } + handleRefresh = () => { + this.props.dispatch(fetchReblogs(this.props.params.statusId)); + } + render () { - const { intl, accountIds } = this.props; + const { intl, accountIds, multiColumn } = this.props; if (!accountIds) { return ( @@ -64,6 +68,8 @@ export default class Reblogs extends ImmutablePureComponent { ); } + const emptyMessage = <FormattedMessage id='status.reblogs.empty' defaultMessage='No one has boosted this toot yet. When someone does, they will show up here.' />; + return ( <Column ref={this.setRef}> <ColumnHeader @@ -71,13 +77,21 @@ export default class Reblogs extends ImmutablePureComponent { title={intl.formatMessage(messages.heading)} onClick={this.handleHeaderClick} showBackButton + multiColumn={multiColumn} + extraButton={( + <button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button> + )} /> - <ScrollContainer scrollKey='reblogs' shouldUpdateScroll={this.shouldUpdateScroll}> - <div className='scrollable reblogs'> - {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} - </div> - </ScrollContainer> + <ScrollableList + scrollKey='reblogs' + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} withNote={false} /> + )} + </ScrollableList> </Column> ); } diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js index 8291319c3..c48bfaccd 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -32,8 +32,8 @@ const messages = defineMessages({ copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, }); -@injectIntl -export default class ActionBar extends React.PureComponent { +export default @injectIntl +class ActionBar extends React.PureComponent { static contextTypes = { router: PropTypes.object, diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js index f974a87a1..7352dc6b4 100644 --- a/app/javascript/flavours/glitch/features/status/components/card.js +++ b/app/javascript/flavours/glitch/features/status/components/card.js @@ -4,15 +4,8 @@ import Immutable from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; import punycode from 'punycode'; import classnames from 'classnames'; - -const IDNA_PREFIX = 'xn--'; - -const decodeIDNA = domain => { - return domain - .split('.') - .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) - .join('.'); -}; +import { decode as decodeIDNA } from 'flavours/glitch/util/idna'; +import Icon from 'flavours/glitch/components/icon'; const getHostname = url => { const parser = document.createElement('a'); @@ -147,7 +140,7 @@ export default class Card extends React.PureComponent { const horizontal = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded; const interactive = card.get('type') !== 'link'; const className = classnames('status-card', { horizontal, compact, interactive }); - const title = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>; + const title = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener noreferrer' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>; const ratio = card.get('width') / card.get('height'); const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); @@ -178,8 +171,8 @@ export default class Card extends React.PureComponent { <div className='status-card__actions'> <div> - <button onClick={this.handleEmbedClick}><i className={`fa fa-${iconVariant}`} /></button> - {horizontal && <a href={card.get('url')} target='_blank' rel='noopener'><i className='fa fa-external-link' /></a>} + <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button> + {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>} </div> </div> </div> @@ -201,13 +194,13 @@ export default class Card extends React.PureComponent { } else { embed = ( <div className='status-card__image'> - <i className='fa fa-file-text' /> + <Icon id='file-text' /> </div> ); } return ( - <a href={card.get('url')} className={className} target='_blank' rel='noopener' ref={this.setRef}> + <a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}> {embed} {description} </a> diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index ddedac4d4..898011c88 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -11,10 +11,12 @@ import { FormattedDate, FormattedNumber } from 'react-intl'; import Card from './card'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Video from 'flavours/glitch/features/video'; +import Audio from 'flavours/glitch/features/audio'; import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon'; import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task'; import classNames from 'classnames'; import PollContainer from 'flavours/glitch/containers/poll_container'; +import Icon from 'flavours/glitch/components/icon'; export default class DetailedStatus extends ImmutablePureComponent { @@ -131,14 +133,27 @@ export default class DetailedStatus extends ImmutablePureComponent { } else if (status.get('media_attachments').size > 0) { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { media = <AttachmentList media={status.get('media_attachments')} />; + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + <Audio + src={attachment.get('url')} + alt={attachment.get('description')} + duration={attachment.getIn(['meta', 'original', 'duration'], 0)} + height={110} + preload + /> + ); + mediaIcon = 'music'; } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - const video = status.getIn(['media_attachments', 0]); + const attachment = status.getIn(['media_attachments', 0]); media = ( <Video - preview={video.get('preview_url')} - blurhash={video.get('blurhash')} - src={video.get('url')} - alt={video.get('description')} + preview={attachment.get('preview_url')} + blurhash={attachment.get('blurhash')} + src={attachment.get('url')} + alt={attachment.get('description')} inline sensitive={status.get('sensitive')} letterbox={settings.getIn(['media', 'letterbox'])} @@ -173,7 +188,7 @@ export default class DetailedStatus extends ImmutablePureComponent { } if (status.get('application')) { - applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>; + applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></span>; } if (status.get('visibility') === 'direct') { @@ -183,11 +198,11 @@ export default class DetailedStatus extends ImmutablePureComponent { } if (status.get('visibility') === 'private') { - reblogLink = <i className={`fa fa-${reblogIcon}`} />; + reblogLink = <Icon id={reblogIcon} />; } else if (this.context.router) { reblogLink = ( <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'> - <i className={`fa fa-${reblogIcon}`} /> + <Icon id={reblogIcon} /> <span className='detailed-status__reblogs'> <FormattedNumber value={status.get('reblogs_count')} /> </span> @@ -196,7 +211,7 @@ export default class DetailedStatus extends ImmutablePureComponent { } else { reblogLink = ( <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}> - <i className={`fa fa-${reblogIcon}`} /> + <Icon id={reblogIcon} /> <span className='detailed-status__reblogs'> <FormattedNumber value={status.get('reblogs_count')} /> </span> @@ -207,7 +222,7 @@ export default class DetailedStatus extends ImmutablePureComponent { if (this.context.router) { favouriteLink = ( <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'> - <i className='fa fa-star' /> + <Icon id='star' /> <span className='detailed-status__favorites'> <FormattedNumber value={status.get('favourites_count')} /> </span> @@ -216,7 +231,7 @@ export default class DetailedStatus extends ImmutablePureComponent { } else { favouriteLink = ( <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}> - <i className='fa fa-star' /> + <Icon id='star' /> <span className='detailed-status__favorites'> <FormattedNumber value={status.get('favourites_count')} /> </span> @@ -241,11 +256,13 @@ export default class DetailedStatus extends ImmutablePureComponent { onExpandedToggle={onToggleHidden} parseClick={this.parseClick} onUpdate={this.handleChildUpdate} + tagLinks={settings.get('tag_misleading_links')} + rewriteMentions={settings.get('rewrite_mentions')} disabled /> <div className='detailed-status__meta'> - <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'> + <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'> <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> </a>{applicationLink} · {reblogLink} · {favouriteLink} · <VisibilityIcon visibility={status.get('visibility')} /> </div> diff --git a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js index e6c390537..e71803328 100644 --- a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js +++ b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js @@ -1,4 +1,3 @@ -import React from 'react'; import { connect } from 'react-redux'; import DetailedStatus from '../components/detailed_status'; import { makeGetStatus } from 'flavours/glitch/selectors'; @@ -15,7 +14,6 @@ import { pin, unpin, } from 'flavours/glitch/actions/interactions'; -import { blockAccount } from 'flavours/glitch/actions/accounts'; import { muteStatus, unmuteStatus, @@ -24,9 +22,10 @@ import { revealStatus, } from 'flavours/glitch/actions/statuses'; import { initMuteModal } from 'flavours/glitch/actions/mutes'; +import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initReport } from 'flavours/glitch/actions/reports'; import { openModal } from 'flavours/glitch/actions/modal'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; import { boostModal, deleteModal } from 'flavours/glitch/util/initial_state'; import { showAlertForError } from 'flavours/glitch/actions/alerts'; @@ -35,10 +34,8 @@ const messages = defineMessages({ deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' }, - blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, - blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, }); const makeMapStateToProps = () => { @@ -139,16 +136,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onBlock (status) { const account = status.get('account'); - dispatch(openModal('CONFIRM', { - message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, - confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.get('id'))), - secondary: intl.formatMessage(messages.blockAndReport), - onSecondary: () => { - dispatch(blockAccount(account.get('id'))); - dispatch(initReport(account, status)); - }, - })); + dispatch(initBlockModal(account)); }, onReport (status) { diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 76bfaaffa..322f92477 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { createSelector } from 'reselect'; import { fetchStatus } from 'flavours/glitch/actions/statuses'; import MissingIndicator from 'flavours/glitch/components/missing_indicator'; import DetailedStatus from './components/detailed_status'; @@ -25,9 +26,9 @@ import { directCompose, } from 'flavours/glitch/actions/compose'; import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; -import { blockAccount } from 'flavours/glitch/actions/accounts'; import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses'; import { initMuteModal } from 'flavours/glitch/actions/mutes'; +import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initReport } from 'flavours/glitch/actions/reports'; import { makeGetStatus } from 'flavours/glitch/selectors'; import { ScrollContainer } from 'react-router-scroll-4'; @@ -35,67 +36,95 @@ import ColumnBackButton from 'flavours/glitch/components/column_back_button'; import ColumnHeader from '../../components/column_header'; import StatusContainer from 'flavours/glitch/containers/status_container'; import { openModal } from 'flavours/glitch/actions/modal'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { HotKeys } from 'react-hotkeys'; import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'flavours/glitch/util/fullscreen'; import { autoUnfoldCW } from 'flavours/glitch/util/content_warning'; import { textForScreenReader, defaultMediaVisibility } from 'flavours/glitch/components/status'; +import Icon from 'flavours/glitch/components/icon'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' }, - blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' }, hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' }, detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, - blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, tootHeading: { id: 'column.toot', defaultMessage: 'Toots and replies' }, }); const makeMapStateToProps = () => { const getStatus = makeGetStatus(); - const mapStateToProps = (state, props) => { - const status = getStatus(state, { id: props.params.statusId }); + const getAncestorsIds = createSelector([ + (_, { id }) => id, + state => state.getIn(['contexts', 'inReplyTos']), + ], (statusId, inReplyTos) => { let ancestorsIds = Immutable.List(); - let descendantsIds = Immutable.List(); + ancestorsIds = ancestorsIds.withMutations(mutable => { + let id = statusId; - if (status) { - ancestorsIds = ancestorsIds.withMutations(mutable => { - let id = status.get('in_reply_to_id'); + while (id) { + mutable.unshift(id); + id = inReplyTos.get(id); + } + }); - while (id) { - mutable.unshift(id); - id = state.getIn(['contexts', 'inReplyTos', id]); - } - }); + return ancestorsIds; + }); + + const getDescendantsIds = createSelector([ + (_, { id }) => id, + state => state.getIn(['contexts', 'replies']), + state => state.get('statuses'), + ], (statusId, contextReplies, statuses) => { + let descendantsIds = []; + const ids = [statusId]; - descendantsIds = descendantsIds.withMutations(mutable => { - const ids = [status.get('id')]; + while (ids.length > 0) { + let id = ids.shift(); + const replies = contextReplies.get(id); - while (ids.length > 0) { - let id = ids.shift(); - const replies = state.getIn(['contexts', 'replies', id]); + if (statusId !== id) { + descendantsIds.push(id); + } - if (status.get('id') !== id) { - mutable.push(id); - } + if (replies) { + replies.reverse().forEach(reply => { + ids.unshift(reply); + }); + } + } - if (replies) { - replies.reverse().forEach(reply => { - ids.unshift(reply); - }); - } + let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account')); + if (insertAt !== -1) { + descendantsIds.forEach((id, idx) => { + if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) { + descendantsIds.splice(idx, 1); + descendantsIds.splice(insertAt, 0, id); + insertAt += 1; } }); } + return Immutable.List(descendantsIds); + }); + + const mapStateToProps = (state, props) => { + const status = getStatus(state, { id: props.params.statusId }); + let ancestorsIds = Immutable.List(); + let descendantsIds = Immutable.List(); + + if (status) { + ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') }); + descendantsIds = getDescendantsIds(state, { id: status.get('id') }); + } + return { status, ancestorsIds, @@ -109,9 +138,9 @@ const makeMapStateToProps = () => { return mapStateToProps; }; -@injectIntl +export default @injectIntl @connect(makeMapStateToProps) -export default class Status extends ImmutablePureComponent { +class Status extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -126,6 +155,7 @@ export default class Status extends ImmutablePureComponent { descendantsIds: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, askReplyConfirmation: PropTypes.bool, + multiColumn: PropTypes.bool, domain: PropTypes.string.isRequired, }; @@ -290,6 +320,22 @@ export default class Status extends ImmutablePureComponent { this.props.dispatch(openModal('VIDEO', { media, time })); } + handleHotkeyOpenMedia = e => { + const { status } = this.props; + + e.preventDefault(); + + if (status.get('media_attachments').size > 0) { + if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + // TODO: toggle play/paused? + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + this.handleOpenVideo(status.getIn(['media_attachments', 0]), 0); + } else { + this.handleOpenMedia(status.get('media_attachments'), 0); + } + } + } + handleMuteClick = (account) => { this.props.dispatch(initMuteModal(account)); } @@ -308,19 +354,9 @@ export default class Status extends ImmutablePureComponent { } handleBlockClick = (status) => { - const { dispatch, intl } = this.props; + const { dispatch } = this.props; const account = status.get('account'); - - dispatch(openModal('CONFIRM', { - message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, - confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.get('id'))), - secondary: intl.formatMessage(messages.blockAndReport), - onSecondary: () => { - dispatch(blockAccount(account.get('id'))); - dispatch(initReport(account, status)); - }, - })); + dispatch(initBlockModal(account)); } handleReport = (status) => { @@ -478,13 +514,13 @@ export default class Status extends ImmutablePureComponent { render () { let ancestors, descendants; const { setExpansion } = this; - const { status, settings, ancestorsIds, descendantsIds, intl, domain } = this.props; + const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props; const { fullscreen, isExpanded } = this.state; if (status === null) { return ( <Column> - <ColumnBackButton /> + <ColumnBackButton multiColumn={multiColumn} /> <MissingIndicator /> </Column> ); @@ -509,17 +545,19 @@ export default class Status extends ImmutablePureComponent { openProfile: this.handleHotkeyOpenProfile, toggleSpoiler: this.handleExpandedToggle, toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, }; return ( - <Column ref={this.setColumnRef} label={intl.formatMessage(messages.detailedStatus)}> + <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.detailedStatus)}> <ColumnHeader icon='comment' title={intl.formatMessage(messages.tootHeading)} onClick={this.handleHeaderClick} showBackButton + multiColumn={multiColumn} extraButton={( - <button className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={!isExpanded ? 'false' : 'true'}><i className={`fa fa-${!isExpanded ? 'eye-slash' : 'eye'}`} /></button> + <button className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={!isExpanded ? 'false' : 'true'}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} /></button> )} /> diff --git a/app/javascript/flavours/glitch/features/ui/components/actions_modal.js b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js index 724f1c764..24169036c 100644 --- a/app/javascript/flavours/glitch/features/ui/components/actions_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js @@ -68,8 +68,8 @@ export default class ActionsModal extends ImmutablePureComponent { return ( <Icon className='icon' - fullwidth - icon={icon} + fixedWidth + id={icon} /> ); default: @@ -92,12 +92,12 @@ export default class ActionsModal extends ImmutablePureComponent { <div className='status light'> <div className='boost-modal__status-header'> <div className='boost-modal__status-time'> - <a href={this.props.status.get('url')} className='status__relative-time' target='_blank' rel='noopener'> + <a href={this.props.status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'> <RelativeTimestamp timestamp={this.props.status.get('created_at')} /> </a> </div> - <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name'> + <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name' rel='noopener noreferrer'> <div className='status__avatar'> <Avatar account={this.props.status.get('account')} size={48} /> </div> diff --git a/app/javascript/flavours/glitch/features/ui/components/audio_modal.js b/app/javascript/flavours/glitch/features/ui/components/audio_modal.js new file mode 100644 index 000000000..08fbddc91 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/audio_modal.js @@ -0,0 +1,53 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Audio from 'flavours/glitch/features/audio'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; + +export default class AudioModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + status: ImmutablePropTypes.map, + onClose: PropTypes.func.isRequired, + }; + + static contextTypes = { + router: PropTypes.object, + }; + + handleStatusClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + } + } + + render () { + const { media, status } = this.props; + + return ( + <div className='modal-root__modal audio-modal'> + <div className='audio-modal__container'> + <Audio + src={media.get('url')} + alt={media.get('description')} + duration={media.getIn(['meta', 'original', 'duration'], 0)} + height={135} + preload + /> + </div> + + {status && ( + <div className={classNames('media-modal__meta')}> + <a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> + </div> + )} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/block_modal.js b/app/javascript/flavours/glitch/features/ui/components/block_modal.js new file mode 100644 index 000000000..a07baeaa6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/block_modal.js @@ -0,0 +1,103 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import { makeGetAccount } from '../../../selectors'; +import Button from '../../../components/button'; +import { closeModal } from '../../../actions/modal'; +import { blockAccount } from '../../../actions/accounts'; +import { initReport } from '../../../actions/reports'; + + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = state => ({ + account: getAccount(state, state.getIn(['blocks', 'new', 'account_id'])), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => { + return { + onConfirm(account) { + dispatch(blockAccount(account.get('id'))); + }, + + onBlockAndReport(account) { + dispatch(blockAccount(account.get('id'))); + dispatch(initReport(account)); + }, + + onClose() { + dispatch(closeModal()); + }, + }; +}; + +export default @connect(makeMapStateToProps, mapDispatchToProps) +@injectIntl +class BlockModal extends React.PureComponent { + + static propTypes = { + account: PropTypes.object.isRequired, + onClose: PropTypes.func.isRequired, + onBlockAndReport: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(this.props.account); + } + + handleSecondary = () => { + this.props.onClose(); + this.props.onBlockAndReport(this.props.account); + } + + handleCancel = () => { + this.props.onClose(); + } + + setRef = (c) => { + this.button = c; + } + + render () { + const { account } = this.props; + + return ( + <div className='modal-root__modal block-modal'> + <div className='block-modal__container'> + <p> + <FormattedMessage + id='confirmations.block.message' + defaultMessage='Are you sure you want to block {name}?' + values={{ name: <strong>@{account.get('acct')}</strong> }} + /> + </p> + </div> + + <div className='block-modal__action-bar'> + <Button onClick={this.handleCancel} className='block-modal__cancel-button'> + <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> + </Button> + <Button onClick={this.handleSecondary} className='confirmation-modal__secondary-button'> + <FormattedMessage id='confirmations.block.block_and_report' defaultMessage='Block & Report' /> + </Button> + <Button onClick={this.handleClick} ref={this.setRef}> + <FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' /> + </Button> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js index 600e4422f..cd2929fdb 100644 --- a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js @@ -8,6 +8,7 @@ import Avatar from 'flavours/glitch/components/avatar'; import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; import DisplayName from 'flavours/glitch/components/display_name'; import AttachmentList from 'flavours/glitch/components/attachment_list'; +import Icon from 'flavours/glitch/components/icon'; import ImmutablePureComponent from 'react-immutable-pure-component'; const messages = defineMessages({ @@ -15,8 +16,8 @@ const messages = defineMessages({ reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, }); -@injectIntl -export default class BoostModal extends ImmutablePureComponent { +export default @injectIntl +class BoostModal extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -63,7 +64,7 @@ export default class BoostModal extends ImmutablePureComponent { <div className='status light'> <div className='boost-modal__status-header'> <div className='boost-modal__status-time'> - <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a> </div> <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'> @@ -91,7 +92,7 @@ export default class BoostModal extends ImmutablePureComponent { { missingMediaDescription ? <FormattedMessage id='boost_modal.missing_description' defaultMessage='This toot contains some media without description' /> : - <FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-retweet' /></span> }} /> + <FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} /> } </div> <Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} /> diff --git a/app/javascript/flavours/glitch/features/ui/components/column_header.js b/app/javascript/flavours/glitch/features/ui/components/column_header.js index e8bdd8054..528ff73a6 100644 --- a/app/javascript/flavours/glitch/features/ui/components/column_header.js +++ b/app/javascript/flavours/glitch/features/ui/components/column_header.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; export default class ColumnHeader extends React.PureComponent { @@ -21,7 +22,7 @@ export default class ColumnHeader extends React.PureComponent { let iconElement = ''; if (icon) { - iconElement = <i className={`fa fa-fw fa-${icon} column-header__icon`} />; + iconElement = <Icon id={icon} fixedWidth className='column-header__icon' />; } return ( diff --git a/app/javascript/flavours/glitch/features/ui/components/column_link.js b/app/javascript/flavours/glitch/features/ui/components/column_link.js index 1b6d7d09e..d04b869b6 100644 --- a/app/javascript/flavours/glitch/features/ui/components/column_link.js +++ b/app/javascript/flavours/glitch/features/ui/components/column_link.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; +import Icon from 'flavours/glitch/components/icon'; const ColumnLink = ({ icon, text, to, onClick, href, method, badge }) => { const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null; @@ -8,7 +9,7 @@ const ColumnLink = ({ icon, text, to, onClick, href, method, badge }) => { if (href) { return ( <a href={href} className='column-link' data-method={method}> - <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + <Icon id={icon} fixedWidth className='column-link__icon' /> {text} {badgeElement} </a> @@ -16,7 +17,7 @@ const ColumnLink = ({ icon, text, to, onClick, href, method, badge }) => { } else if (to) { return ( <Link to={to} className='column-link'> - <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + <Icon id={icon} fixedWidth className='column-link__icon' /> {text} {badgeElement} </Link> @@ -29,7 +30,7 @@ const ColumnLink = ({ icon, text, to, onClick, href, method, badge }) => { } return ( <a href='#' onClick={onClick && handleOnClick} className='column-link' tabIndex='0'> - <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + <Icon id={icon} fixedWidth className='column-link__icon' /> {text} {badgeElement} </a> diff --git a/app/javascript/flavours/glitch/features/ui/components/column_loading.js b/app/javascript/flavours/glitch/features/ui/components/column_loading.js index ba2d0824e..22c00c915 100644 --- a/app/javascript/flavours/glitch/features/ui/components/column_loading.js +++ b/app/javascript/flavours/glitch/features/ui/components/column_loading.js @@ -21,7 +21,7 @@ export default class ColumnLoading extends ImmutablePureComponent { let { title, icon } = this.props; return ( <Column> - <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} /> + <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} placeholder /> <div className='scrollable' /> </Column> ); diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js index 3a188ca87..431909c72 100644 --- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js +++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js @@ -12,7 +12,20 @@ import BundleContainer from '../containers/bundle_container'; import ColumnLoading from './column_loading'; import DrawerLoading from './drawer_loading'; import BundleColumnError from './bundle_column_error'; -import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, BookmarkedStatuses, ListTimeline } from 'flavours/glitch/util/async-components'; +import { + Compose, + Notifications, + HomeTimeline, + CommunityTimeline, + PublicTimeline, + HashtagTimeline, + DirectTimeline, + FavouritedStatuses, + BookmarkedStatuses, + ListTimeline, + Directory, +} from 'flavours/glitch/util/async-components'; +import Icon from 'flavours/glitch/components/icon'; import ComposePanel from './compose_panel'; import NavigationPanel from './navigation_panel'; @@ -30,6 +43,7 @@ const componentMap = { 'FAVOURITES': FavouritedStatuses, 'BOOKMARKS': BookmarkedStatuses, 'LIST': ListTimeline, + 'DIRECTORY': Directory, }; const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/); @@ -38,8 +52,8 @@ const messages = defineMessages({ publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }, }); -@component => injectIntl(component, { withRef: true }) -export default class ColumnsArea extends ImmutablePureComponent { +export default @(component => injectIntl(component, { withRef: true })) +class ColumnsArea extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object.isRequired, @@ -112,6 +126,11 @@ export default class ColumnsArea extends ImmutablePureComponent { // React-router does this for us, but too late, feeling laggy. document.querySelector(currentLinkSelector).classList.remove('active'); document.querySelector(nextLinkSelector).classList.add('active'); + + if (!this.state.shouldAnimate && typeof this.pendingIndex === 'number') { + this.context.router.history.push(getLink(this.pendingIndex)); + this.pendingIndex = null; + } } handleAnimationEnd = () => { @@ -162,13 +181,12 @@ export default class ColumnsArea extends ImmutablePureComponent { const { shouldAnimate } = this.state; const columnIndex = getIndex(this.context.router.history.location.pathname); - this.pendingIndex = null; if (singleColumn) { - const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><i className='fa fa-pencil' /></Link>; + const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>; const content = columnIndex !== -1 ? ( - <ReactSwipeableViews key='content' index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={!swipeToChangeColumns}> + <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={!swipeToChangeColumns}> {links.map(this.renderView)} </ReactSwipeableViews> ) : ( diff --git a/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js index 970df30b6..47a49c0c7 100644 --- a/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js @@ -3,8 +3,8 @@ import PropTypes from 'prop-types'; import { injectIntl, FormattedMessage } from 'react-intl'; import Button from 'flavours/glitch/components/button'; -@injectIntl -export default class ConfirmationModal extends React.PureComponent { +export default @injectIntl +class ConfirmationModal extends React.PureComponent { static propTypes = { message: PropTypes.node.isRequired, diff --git a/app/javascript/flavours/glitch/features/ui/components/doodle_modal.js b/app/javascript/flavours/glitch/features/ui/components/doodle_modal.js index 72f7f30b9..0d10204fc 100644 --- a/app/javascript/flavours/glitch/features/ui/components/doodle_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/doodle_modal.js @@ -145,8 +145,8 @@ const mapDispatchToProps = dispatch => ({ * - Ctrl + left mouse button: pick background * - Right mouse button: pick background */ -@connect(mapStateToProps, mapDispatchToProps) -export default class DoodleModal extends ImmutablePureComponent { +export default @connect(mapStateToProps, mapDispatchToProps) +class DoodleModal extends ImmutablePureComponent { static propTypes = { options: ImmutablePropTypes.map, diff --git a/app/javascript/flavours/glitch/features/ui/components/embed_modal.js b/app/javascript/flavours/glitch/features/ui/components/embed_modal.js index b1643df1c..b6f5e628d 100644 --- a/app/javascript/flavours/glitch/features/ui/components/embed_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/embed_modal.js @@ -1,11 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage, injectIntl } from 'react-intl'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; import api from 'flavours/glitch/util/api'; +import IconButton from 'flavours/glitch/components/icon_button'; -@injectIntl -export default class EmbedModal extends ImmutablePureComponent { +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +export default @injectIntl +class EmbedModal extends ImmutablePureComponent { static propTypes = { url: PropTypes.string.isRequired, @@ -50,13 +55,17 @@ export default class EmbedModal extends ImmutablePureComponent { } render () { + const { intl, onClose } = this.props; const { oembed } = this.state; return ( - <div className='modal-root__modal embed-modal'> - <h4><FormattedMessage id='status.embed' defaultMessage='Embed' /></h4> + <div className='modal-root__modal report-modal embed-modal'> + <div className='report-modal__target'> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> + <FormattedMessage id='status.embed' defaultMessage='Embed' /> + </div> - <div className='embed-modal__container'> + <div className='report-modal__container embed-modal__container' style={{ display: 'block' }}> <p className='hint'> <FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' /> </p> diff --git a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js index e0037a15f..176e7c487 100644 --- a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js @@ -7,14 +7,15 @@ import StatusContent from 'flavours/glitch/components/status_content'; import Avatar from 'flavours/glitch/components/avatar'; import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; import DisplayName from 'flavours/glitch/components/display_name'; +import Icon from 'flavours/glitch/components/icon'; import ImmutablePureComponent from 'react-immutable-pure-component'; const messages = defineMessages({ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, }); -@injectIntl -export default class FavouriteModal extends ImmutablePureComponent { +export default @injectIntl +class FavouriteModal extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -76,7 +77,7 @@ export default class FavouriteModal extends ImmutablePureComponent { </div> <div className='favourite-modal__action-bar'> - <div><FormattedMessage id='favourite_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-star' /></span> }} /></div> + <div><FormattedMessage id='favourite_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='star' /></span> }} /></div> <Button text={intl.formatMessage(messages.favourite)} onClick={this.handleFavourite} ref={this.setRef} /> </div> </div> diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js index 57c92cc66..77e4bbfa5 100644 --- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js @@ -1,11 +1,28 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; -import ImageLoader from './image_loader'; import classNames from 'classnames'; import { changeUploadCompose } from 'flavours/glitch/actions/compose'; import { getPointerPosition } from 'flavours/glitch/features/video'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Button from 'flavours/glitch/components/button'; +import Video from 'flavours/glitch/features/video'; +import Audio from 'flavours/glitch/features/audio'; +import Textarea from 'react-textarea-autosize'; +import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress'; +import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter'; +import { length } from 'stringz'; +import { Tesseract as fetchTesseract } from 'flavours/glitch/util/async-components'; +import GIFV from 'flavours/glitch/components/gifv'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' }, + placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' }, +}); const mapStateToProps = (state, { id }) => ({ media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), @@ -13,17 +30,56 @@ const mapStateToProps = (state, { id }) => ({ const mapDispatchToProps = (dispatch, { id }) => ({ - onSave: (x, y) => { - dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` })); + onSave: (description, x, y) => { + dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` })); }, }); -@connect(mapStateToProps, mapDispatchToProps) -export default class FocalPointModal extends ImmutablePureComponent { +const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') + .replace(/\n/g, ' ') + .replace(/\*\*\*\*\*\*/g, '\n\n'); + +const assetHost = process.env.CDN_HOST || ''; + +class ImageLoader extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + }; + + state = { + loading: true, + }; + + componentDidMount() { + const image = new Image(); + image.addEventListener('load', () => this.setState({ loading: false })); + image.src = this.props.src; + } + + render () { + const { loading } = this.state; + + if (loading) { + return <canvas width={this.props.width} height={this.props.height} />; + } else { + return <img {...this.props} alt='' />; + } + } + +} + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class FocalPointModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, }; state = { @@ -32,6 +88,10 @@ export default class FocalPointModal extends ImmutablePureComponent { focusX: 0, focusY: 0, dragging: false, + description: '', + dirty: false, + progress: 0, + loading: true, }; componentWillMount () { @@ -57,6 +117,14 @@ export default class FocalPointModal extends ImmutablePureComponent { this.setState({ dragging: true }); } + handleTouchStart = e => { + document.addEventListener('touchmove', this.handleMouseMove); + document.addEventListener('touchend', this.handleTouchEnd); + + this.updatePosition(e); + this.setState({ dragging: true }); + } + handleMouseMove = e => { this.updatePosition(e); } @@ -66,7 +134,13 @@ export default class FocalPointModal extends ImmutablePureComponent { document.removeEventListener('mouseup', this.handleMouseUp); this.setState({ dragging: false }); - this.props.onSave(this.state.focusX, this.state.focusY); + } + + handleTouchEnd = () => { + document.removeEventListener('touchmove', this.handleMouseMove); + document.removeEventListener('touchend', this.handleTouchEnd); + + this.setState({ dragging: false }); } updatePosition = e => { @@ -74,46 +148,188 @@ export default class FocalPointModal extends ImmutablePureComponent { const focusX = (x - .5) * 2; const focusY = (y - .5) * -2; - this.setState({ x, y, focusX, focusY }); + this.setState({ x, y, focusX, focusY, dirty: true }); } updatePositionFromMedia = media => { - const focusX = media.getIn(['meta', 'focus', 'x']); - const focusY = media.getIn(['meta', 'focus', 'y']); + const focusX = media.getIn(['meta', 'focus', 'x']); + const focusY = media.getIn(['meta', 'focus', 'y']); + const description = media.get('description') || ''; if (focusX && focusY) { const x = (focusX / 2) + .5; const y = (focusY / -2) + .5; - this.setState({ x, y, focusX, focusY }); + this.setState({ + x, + y, + focusX, + focusY, + description, + dirty: false, + }); } else { - this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 }); + this.setState({ + x: 0.5, + y: 0.5, + focusX: 0, + focusY: 0, + description, + dirty: false, + }); } } + handleChange = e => { + this.setState({ description: e.target.value, dirty: true }); + } + + handleKeyDown = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + this.setState({ description: e.target.value, dirty: true }); + this.handleSubmit(); + } + } + + handleSubmit = () => { + this.props.onSave(this.state.description, this.state.focusX, this.state.focusY); + this.props.onClose(); + } + setRef = c => { this.node = c; } - render () { + handleTextDetection = () => { const { media } = this.props; - const { x, y, dragging } = this.state; + + this.setState({ detecting: true }); + + fetchTesseract().then(({ TesseractWorker }) => { + const worker = new TesseractWorker({ + workerPath: `${assetHost}/packs/ocr/worker.min.js`, + corePath: `${assetHost}/packs/ocr/tesseract-core.wasm.js`, + langPath: `${assetHost}/ocr/lang-data`, + }); + + let media_url = media.get('url'); + + if (window.URL && URL.createObjectURL) { + try { + media_url = URL.createObjectURL(media.get('file')); + } catch (error) { + console.error(error); + } + } + + worker.recognize(media_url) + .progress(({ progress }) => this.setState({ progress })) + .finally(() => worker.terminate()) + .then(({ text }) => this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false })) + .catch(() => this.setState({ detecting: false })); + }).catch(() => this.setState({ detecting: false })); + } + + render () { + const { media, intl, onClose } = this.props; + const { x, y, dragging, description, dirty, detecting, progress } = this.state; const width = media.getIn(['meta', 'original', 'width']) || null; const height = media.getIn(['meta', 'original', 'height']) || null; + const focals = ['image', 'gifv'].includes(media.get('type')); + + const previewRatio = 16/9; + const previewWidth = 200; + const previewHeight = previewWidth / previewRatio; + + let descriptionLabel = null; + + if (media.get('type') === 'audio') { + descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people with hearing loss' />; + } else if (media.get('type') === 'video') { + descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people with hearing loss or visual impairment' />; + } else { + descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' />; + } return ( - <div className='modal-root__modal video-modal focal-point-modal'> - <div className={classNames('focal-point', { dragging })} ref={this.setRef}> - <ImageLoader - previewSrc={media.get('preview_url')} - src={media.get('url')} - width={width} - height={height} - /> - - <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} /> - <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} /> + <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}> + <div className='report-modal__target'> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> + <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' /> + </div> + + <div className='report-modal__container'> + <div className='report-modal__comment'> + {focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>} + + <label className='setting-text-label' htmlFor='upload-modal__description'> + {descriptionLabel} + </label> + + <div className='setting-text__wrapper'> + <Textarea + id='upload-modal__description' + className='setting-text light' + value={detecting ? '…' : description} + onChange={this.handleChange} + onKeyDown={this.handleKeyDown} + disabled={detecting} + autoFocus + /> + + <div className='setting-text__modifiers'> + <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={<FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />} /> + </div> + </div> + + <div className='setting-text__toolbar'> + <button disabled={detecting || media.get('type') !== 'image'} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button> + <CharacterCounter max={1500} text={detecting ? '' : description} /> + </div> + + <Button disabled={!dirty || detecting || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} /> + </div> + + <div className='focal-point-modal__content'> + {focals && ( + <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}> + {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />} + {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />} + + <div className='focal-point__preview'> + <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong> + <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} /> + </div> + + <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} /> + <div className='focal-point__overlay' /> + </div> + )} + + {media.get('type') === 'video' && ( + <Video + preview={media.get('preview_url')} + blurhash={media.get('blurhash')} + src={media.get('url')} + detailed + inline + editable + /> + )} + + {media.get('type') === 'audio' && ( + <Audio + src={media.get('url')} + duration={media.getIn(['meta', 'original', 'duration'], 0)} + height={150} + preload + editable + /> + )} + </div> </div> </div> ); diff --git a/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js b/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js index 189f403bd..c30427896 100644 --- a/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js +++ b/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js @@ -4,12 +4,10 @@ import { fetchFollowRequests } from 'flavours/glitch/actions/accounts'; import { connect } from 'react-redux'; import { NavLink, withRouter } from 'react-router-dom'; import IconWithBadge from 'flavours/glitch/components/icon_with_badge'; -import { me } from 'flavours/glitch/util/initial_state'; import { List as ImmutableList } from 'immutable'; import { FormattedMessage } from 'react-intl'; const mapStateToProps = state => ({ - locked: state.getIn(['accounts', me, 'locked']), count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size, }); @@ -19,22 +17,19 @@ class FollowRequestsNavLink extends React.Component { static propTypes = { dispatch: PropTypes.func.isRequired, - locked: PropTypes.bool, count: PropTypes.number.isRequired, }; componentDidMount () { - const { dispatch, locked } = this.props; + const { dispatch } = this.props; - if (locked) { - dispatch(fetchFollowRequests()); - } + dispatch(fetchFollowRequests()); } render () { - const { locked, count } = this.props; + const { count } = this.props; - if (!locked || count === 0) { + if (count === 0) { return null; } diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js index e63ed274e..f8d0d528d 100644 --- a/app/javascript/flavours/glitch/features/ui/components/link_footer.js +++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js @@ -1,37 +1,72 @@ +import { connect } from 'react-redux'; import React from 'react'; import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { Link } from 'react-router-dom'; import { invitesEnabled, version, repository, source_url } from 'flavours/glitch/util/initial_state'; -import { signOutLink } from 'flavours/glitch/util/backend_links'; - -const LinkFooter = () => ( - <div className='getting-started__footer'> - <ul> - {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>} - <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li> - <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li> - <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li> - <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li> - <li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li> - <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li> - <li><a href={signOutLink} data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li> - </ul> - - <p> - <FormattedMessage - id='getting_started.open_source_notice' - defaultMessage='GlitchCafe is open source software, based on {Glitchsoc} which is a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.' - values={{ - github: <span><a href='https://github.com/pluralcafe/mastodon' rel='noopener' target='_blank'>pluralcafe/mastodon</a> (v{version})</span>, - Glitchsoc: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>, - Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }} - /> - </p> - </div> -); - -LinkFooter.propTypes = { -}; +import { signOutLink, securityLink } from 'flavours/glitch/util/backend_links'; +import { logOut } from 'flavours/glitch/util/log_out'; +import { openModal } from 'flavours/glitch/actions/modal'; + +const messages = defineMessages({ + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onLogout () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + onConfirm: () => logOut(), + })); + }, +}); + +export default @injectIntl +@connect(null, mapDispatchToProps) +class LinkFooter extends React.PureComponent { + + static propTypes = { + onLogout: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; -export default LinkFooter; + handleLogoutClick = e => { + e.preventDefault(); + e.stopPropagation(); + + this.props.onLogout(); + + return false; + } + + render () { + return ( + <div className='getting-started__footer'> + <ul> + {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>} + {!!securityLink && <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>} + <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li> + <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li> + <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li> + <li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li> + <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li> + <li><a href={signOutLink} onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li> + </ul> + + <p> + <FormattedMessage + id='getting_started.open_source_notice' + defaultMessage='GlitchCafe is open source software, based on {Glitchsoc} which is a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.' + values={{ + github: <span><a href='https://github.com/pluralcafe/mastodon' rel='noopener' target='_blank'>pluralcafe/mastodon</a> (v{version})</span>, + Glitchsoc: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>, + Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }} + /> + </p> + </div> + ); + } + +}; diff --git a/app/javascript/flavours/glitch/features/ui/components/list_panel.js b/app/javascript/flavours/glitch/features/ui/components/list_panel.js index b2e6925b7..354e35027 100644 --- a/app/javascript/flavours/glitch/features/ui/components/list_panel.js +++ b/app/javascript/flavours/glitch/features/ui/components/list_panel.js @@ -46,7 +46,7 @@ class ListPanel extends ImmutablePureComponent { <hr /> {lists.map(list => ( - <NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/timelines/list/${list.get('id')}`}><Icon className='column-link__icon' icon='list-ul' fixedWidth />{list.get('title')}</NavLink> + <NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/timelines/list/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink> ))} </div> ); diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js index ce6660480..c7d6c374c 100644 --- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js @@ -3,12 +3,13 @@ import ReactSwipeableViews from 'react-swipeable-views'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import Video from 'flavours/glitch/features/video'; -import ExtendedVideoPlayer from 'flavours/glitch/components/extended_video_player'; import classNames from 'classnames'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import IconButton from 'flavours/glitch/components/icon_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; import ImageLoader from './image_loader'; +import Icon from 'flavours/glitch/components/icon'; +import GIFV from 'flavours/glitch/components/gifv'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -16,8 +17,8 @@ const messages = defineMessages({ next: { id: 'lightbox.next', defaultMessage: 'Next' }, }); -@injectIntl -export default class MediaModal extends ImmutablePureComponent { +export default @injectIntl +class MediaModal extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -100,8 +101,8 @@ export default class MediaModal extends ImmutablePureComponent { const index = this.getIndex(); let pagination = []; - const leftNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>; - const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>; + const leftNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' fixedWidth /></button>; + const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' fixedWidth /></button>; if (media.size > 1) { pagination = media.map((item, i) => { @@ -148,10 +149,8 @@ export default class MediaModal extends ImmutablePureComponent { ); } else if (image.get('type') === 'gifv') { return ( - <ExtendedVideoPlayer + <GIFV src={image.get('url')} - muted - controls={false} width={width} height={height} key={image.get('preview_url')} @@ -207,7 +206,7 @@ export default class MediaModal extends ImmutablePureComponent { {status && ( <div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}> - <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> + <a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> </div> )} diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js index 303e05db6..488daf0cc 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Base from '../../../components/modal_root'; +import { getScrollbarWidth } from 'flavours/glitch/util/scrollbar'; +import Base from 'flavours/glitch/components/modal_root'; import BundleContainer from '../containers/bundle_container'; import BundleModalError from './bundle_modal_error'; import ModalLoading from './modal_loading'; @@ -9,12 +10,14 @@ import MediaModal from './media_modal'; import VideoModal from './video_modal'; import BoostModal from './boost_modal'; import FavouriteModal from './favourite_modal'; +import AudioModal from './audio_modal'; import DoodleModal from './doodle_modal'; import ConfirmationModal from './confirmation_modal'; import FocalPointModal from './focal_point_modal'; import { OnboardingModal, MuteModal, + BlockModal, ReportModal, SettingsModal, EmbedModal, @@ -27,11 +30,13 @@ const MODAL_COMPONENTS = { 'MEDIA': () => Promise.resolve({ default: MediaModal }), 'ONBOARDING': OnboardingModal, 'VIDEO': () => Promise.resolve({ default: VideoModal }), + 'AUDIO': () => Promise.resolve({ default: AudioModal }), 'BOOST': () => Promise.resolve({ default: BoostModal }), 'FAVOURITE': () => Promise.resolve({ default: FavouriteModal }), 'DOODLE': () => Promise.resolve({ default: DoodleModal }), 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), 'MUTE': MuteModal, + 'BLOCK': BlockModal, 'REPORT': ReportModal, 'SETTINGS': SettingsModal, 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), @@ -57,8 +62,10 @@ export default class ModalRoot extends React.PureComponent { componentDidUpdate (prevProps, prevState, { visible }) { if (visible) { document.body.classList.add('with-modals--active'); + document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; } else { document.body.classList.remove('with-modals--active'); + document.documentElement.style.marginRight = 0; } } diff --git a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js index 0202b1ab1..2aab82751 100644 --- a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js @@ -11,7 +11,6 @@ import { toggleHideNotifications } from 'flavours/glitch/actions/mutes'; const mapStateToProps = state => { return { - isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), account: state.getIn(['mutes', 'new', 'account']), notifications: state.getIn(['mutes', 'new', 'notifications']), }; @@ -33,12 +32,11 @@ const mapDispatchToProps = dispatch => { }; }; -@connect(mapStateToProps, mapDispatchToProps) +export default @connect(mapStateToProps, mapDispatchToProps) @injectIntl -export default class MuteModal extends React.PureComponent { +class MuteModal extends React.PureComponent { static propTypes = { - isSubmitting: PropTypes.bool.isRequired, account: PropTypes.object.isRequired, notifications: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, @@ -81,11 +79,16 @@ export default class MuteModal extends React.PureComponent { values={{ name: <strong>@{account.get('acct')}</strong> }} /> </p> - <div> - <label htmlFor='mute-modal__hide-notifications-checkbox'> + <p className='mute-modal__explanation'> + <FormattedMessage + id='confirmations.mute.explanation' + defaultMessage='This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.' + /> + </p> + <div className='setting-toggle'> + <Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} /> + <label className='setting-toggle__label' htmlFor='mute-modal__hide-notifications-checkbox'> <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> - {' '} - <Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} /> </label> </div> </div> diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js index 4688c7766..50e7d5c48 100644 --- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js +++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js @@ -2,30 +2,35 @@ import React from 'react'; import { NavLink, withRouter } from 'react-router-dom'; import { FormattedMessage } from 'react-intl'; import Icon from 'flavours/glitch/components/icon'; -import { profile_directory } from 'flavours/glitch/util/initial_state'; +import { profile_directory, showTrends } from 'flavours/glitch/util/initial_state'; +import { preferencesLink, relationshipsLink } from 'flavours/glitch/util/backend_links'; import NotificationsCounterIcon from './notifications_counter_icon'; import FollowRequestsNavLink from './follow_requests_nav_link'; import ListPanel from './list_panel'; +import TrendsContainer from 'flavours/glitch/features/getting_started/containers/trends_container'; const NavigationPanel = ({ onOpenSettings }) => ( <div className='navigation-panel'> - <NavLink className='column-link column-link--transparent' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' icon='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink> + <NavLink className='column-link column-link--transparent' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink> <NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink> <FollowRequestsNavLink /> - <NavLink className='column-link column-link--transparent' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' icon='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink> - <NavLink className='column-link column-link--transparent' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' icon='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink> - <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' icon='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink> - <NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' icon='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink> - <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' icon='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink> + <NavLink className='column-link column-link--transparent' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink> + <NavLink className='column-link column-link--transparent' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink> + <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink> + <NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink> + {profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></NavLink>} + <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink> <ListPanel /> <hr /> - <a className='column-link column-link--transparent' href='/settings/preferences' target='_blank'><Icon className='column-link__icon' icon='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a> - <a className='column-link column-link--transparent' href='#' onClick={onOpenSettings}><Icon className='column-link__icon' icon='cogs' fixedWidth /><FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' /></a> - <a className='column-link column-link--transparent' href='/relationships' target='_blank'><Icon className='column-link__icon' icon='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a> - {!!profile_directory && <a className='column-link column-link--transparent' href='/explore'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></a>} + {!!preferencesLink && <a className='column-link column-link--transparent' href={preferencesLink} target='_blank'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>} + <a className='column-link column-link--transparent' href='#' onClick={onOpenSettings}><Icon className='column-link__icon' id='cogs' fixedWidth /><FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' /></a> + {!!relationshipsLink && <a className='column-link column-link--transparent' href={relationshipsLink} target='_blank'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>} + + {showTrends && <div className='flex-spacer' />} + {showTrends && <TrendsContainer />} </div> ); diff --git a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js index 3db9ec77d..7419e2cd9 100644 --- a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js @@ -159,9 +159,9 @@ const mapStateToProps = state => ({ domain: state.getIn(['meta', 'domain']), }); -@connect(mapStateToProps) +export default @connect(mapStateToProps) @injectIntl -export default class OnboardingModal extends React.PureComponent { +class OnboardingModal extends React.PureComponent { static propTypes = { onClose: PropTypes.func.isRequired, diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js index 8be1d5856..9016b08d7 100644 --- a/app/javascript/flavours/glitch/features/ui/components/report_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js @@ -37,9 +37,9 @@ const makeMapStateToProps = () => { return mapStateToProps; }; -@connect(makeMapStateToProps) +export default @connect(makeMapStateToProps) @injectIntl -export default class ReportModal extends ImmutablePureComponent { +class ReportModal extends ImmutablePureComponent { static propTypes = { isSubmitting: PropTypes.bool, diff --git a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js index dbd08aa2b..a67405215 100644 --- a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js +++ b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js @@ -4,16 +4,16 @@ import { NavLink, withRouter } from 'react-router-dom'; import { FormattedMessage, injectIntl } from 'react-intl'; import { debounce } from 'lodash'; import { isUserTouching } from 'flavours/glitch/util/is_mobile'; +import Icon from 'flavours/glitch/components/icon'; import NotificationsCounterIcon from './notifications_counter_icon'; export const links = [ - <NavLink className='tabs-bar__link' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>, + <NavLink className='tabs-bar__link' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>, <NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>, - - <NavLink className='tabs-bar__link' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>, - <NavLink className='tabs-bar__link' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>, - <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><i className='fa fa-fw fa-search' /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>, - <NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><i className='fa fa-fw fa-bars' /></NavLink>, + <NavLink className='tabs-bar__link' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>, + <NavLink className='tabs-bar__link' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>, + <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>, + <NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>, ]; export function getIndex (path) { @@ -24,9 +24,9 @@ export function getLink (index) { return links[index].props.to; } -@injectIntl +export default @injectIntl @withRouter -export default class TabsBar extends React.PureComponent { +class TabsBar extends React.PureComponent { static propTypes = { intl: PropTypes.object.isRequired, @@ -73,9 +73,13 @@ export default class TabsBar extends React.PureComponent { const { intl: { formatMessage } } = this.props; return ( - <nav className='tabs-bar' ref={this.setRef}> - {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))} - </nav> + <div className='tabs-bar__wrapper'> + <nav className='tabs-bar' ref={this.setRef}> + {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))} + </nav> + + <div id='tabs-bar__portal' /> + </div> ); } diff --git a/app/javascript/flavours/glitch/features/ui/components/video_modal.js b/app/javascript/flavours/glitch/features/ui/components/video_modal.js index 3f742c260..e7309021e 100644 --- a/app/javascript/flavours/glitch/features/ui/components/video_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js @@ -4,6 +4,8 @@ import PropTypes from 'prop-types'; import Video from 'flavours/glitch/features/video'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; export default class VideoModal extends ImmutablePureComponent { @@ -28,22 +30,25 @@ export default class VideoModal extends ImmutablePureComponent { render () { const { media, status, time, onClose } = this.props; - const link = status && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>; - return ( <div className='modal-root__modal video-modal'> - <div> + <div className='video-modal__container'> <Video preview={media.get('preview_url')} blurhash={media.get('blurhash')} src={media.get('url')} startTime={time} onCloseVideo={onClose} - link={link} detailed alt={media.get('description')} /> </div> + + {status && ( + <div className={classNames('media-modal__meta')}> + <a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> + </div> + )} </div> ); } diff --git a/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js index 283aa2373..82278a3be 100644 --- a/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js +++ b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js @@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => { const value = notification[key]; if (typeof value === 'object') { - notification[key] = intl.formatMessage(value); + notification[key] = intl.formatMessage(value, notification[`${key}_values`]); } })); diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js index e0c017f82..c01d0e5bc 100644 --- a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js +++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js @@ -1,24 +1,31 @@ import { connect } from 'react-redux'; import StatusList from 'flavours/glitch/components/status_list'; -import { scrollTopTimeline } from 'flavours/glitch/actions/timelines'; +import { scrollTopTimeline, loadPending } from 'flavours/glitch/actions/timelines'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { createSelector } from 'reselect'; import { debounce } from 'lodash'; import { me } from 'flavours/glitch/util/initial_state'; -const makeGetStatusIds = () => createSelector([ - (state, { type }) => state.getIn(['settings', type], ImmutableMap()), - (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()), - (state) => state.get('statuses'), -], (columnSettings, statusIds, statuses) => { - const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim(); - let regex = null; +const getRegex = createSelector([ + (state, { type }) => state.getIn(['settings', type, 'regex', 'body']), +], (rawRegex) => { + let regex = null; try { - regex = rawRegex && new RegExp(rawRegex, 'i'); + regex = rawRegex && new RegExp(rawRegex.trim(), 'i'); } catch (e) { // Bad regex, don't affect filters } + return regex; +}); + +const makeGetStatusIds = (pending = false) => createSelector([ + (state, { type }) => state.getIn(['settings', type], ImmutableMap()), + (state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()), + (state) => state.get('statuses'), + getRegex, +], (columnSettings, statusIds, statuses, regex) => { + const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim(); return statusIds.filter(id => { if (id === null) return true; @@ -49,12 +56,14 @@ const makeGetStatusIds = () => createSelector([ const makeMapStateToProps = () => { const getStatusIds = makeGetStatusIds(); + const getPendingStatusIds = makeGetStatusIds(true); const mapStateToProps = (state, { timelineId }) => ({ statusIds: getStatusIds(state, { type: timelineId }), isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), hasMore: state.getIn(['timelines', timelineId, 'hasMore']), + numPending: getPendingStatusIds(state, { type: timelineId }).size, }); return mapStateToProps; @@ -70,6 +79,8 @@ const mapDispatchToProps = (dispatch, { timelineId }) => ({ dispatch(scrollTopTimeline(timelineId, false)); }, 100), + onLoadPending: () => dispatch(loadPending(timelineId)), + }); export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList); diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index 787488db4..5c861fdee 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -12,8 +12,10 @@ import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications'; import { fetchFilters } from 'flavours/glitch/actions/filters'; import { clearHeight } from 'flavours/glitch/actions/height_cache'; +import { submitMarkers } from 'flavours/glitch/actions/markers'; import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers'; import UploadArea from './components/upload_area'; +import PermaLink from 'flavours/glitch/components/permalink'; import ColumnsAreaContainer from './containers/columns_area_container'; import classNames from 'classnames'; import Favico from 'favico.js'; @@ -46,10 +48,11 @@ import { Lists, Search, GettingStartedMisc, + Directory, } from 'flavours/glitch/util/async-components'; import { HotKeys } from 'react-hotkeys'; import { me } from 'flavours/glitch/util/initial_state'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; // Dummy import, to make sure that <Status /> ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. @@ -62,6 +65,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0, hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0, + canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4, layout: state.getIn(['local_settings', 'layout']), isWide: state.getIn(['local_settings', 'stretch']), navbarUnder: state.getIn(['local_settings', 'navbar_under']), @@ -69,6 +73,7 @@ const mapStateToProps = state => ({ unreadNotifications: state.getIn(['notifications', 'unread']), showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']), hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']), + moved: state.getIn(['accounts', me, 'moved']) && state.getIn(['accounts', state.getIn(['accounts', me, 'moved'])]), }); const keyMap = { @@ -102,12 +107,137 @@ const keyMap = { bookmark: 'd', toggleCollapse: 'shift+x', toggleSensitive: 'h', + openMedia: 'e', }; -@connect(mapStateToProps) +class SwitchingColumnsArea extends React.PureComponent { + + static propTypes = { + children: PropTypes.node, + layout: PropTypes.string, + location: PropTypes.object, + navbarUnder: PropTypes.bool, + onLayoutChange: PropTypes.func.isRequired, + }; + + state = { + mobile: isMobile(window.innerWidth, this.props.layout), + }; + + componentWillReceiveProps (nextProps) { + if (nextProps.layout !== this.props.layout) { + this.setState({ mobile: isMobile(window.innerWidth, nextProps.layout) }); + } + } + + componentWillMount () { + window.addEventListener('resize', this.handleResize, { passive: true }); + + if (this.state.mobile) { + document.body.classList.toggle('layout-single-column', true); + document.body.classList.toggle('layout-multiple-columns', false); + } else { + document.body.classList.toggle('layout-single-column', false); + document.body.classList.toggle('layout-multiple-columns', true); + } + } + + componentDidUpdate (prevProps, prevState) { + if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) { + this.node.handleChildrenContentChange(); + } + + if (prevState.mobile !== this.state.mobile) { + document.body.classList.toggle('layout-single-column', this.state.mobile); + document.body.classList.toggle('layout-multiple-columns', !this.state.mobile); + } + } + + componentWillUnmount () { + window.removeEventListener('resize', this.handleResize); + } + + handleLayoutChange = debounce(() => { + // The cached heights are no longer accurate, invalidate + this.props.onLayoutChange(); + }, 500, { + trailing: true, + }) + + handleResize = () => { + const mobile = isMobile(window.innerWidth, this.props.layout); + + if (mobile !== this.state.mobile) { + this.handleLayoutChange.cancel(); + this.props.onLayoutChange(); + this.setState({ mobile }); + } else { + this.handleLayoutChange(); + } + } + + setRef = c => { + if (c) { + this.node = c.getWrappedInstance(); + } + } + + render () { + const { children, navbarUnder } = this.props; + const singleColumn = this.state.mobile; + const redirect = singleColumn ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />; + + return ( + <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn} navbarUnder={navbarUnder}> + <WrappedSwitch> + {redirect} + <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> + <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} /> + <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} /> + <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} /> + <WrappedRoute path='/timelines/public/local' exact component={CommunityTimeline} content={children} /> + <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} /> + <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> + <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} /> + + <WrappedRoute path='/notifications' component={Notifications} content={children} /> + <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> + <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} /> + <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> + + <WrappedRoute path='/search' component={Search} content={children} /> + <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> + + <WrappedRoute path='/statuses/new' component={Compose} content={children} /> + <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> + <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} /> + <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} /> + + <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} /> + <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ withReplies: true }} /> + <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} /> + <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} /> + <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} /> + + <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} /> + <WrappedRoute path='/blocks' component={Blocks} content={children} /> + <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} /> + <WrappedRoute path='/mutes' component={Mutes} content={children} /> + <WrappedRoute path='/lists' component={Lists} content={children} /> + <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} /> + + <WrappedRoute component={GenericNotFound} content={children} /> + </WrappedSwitch> + </ColumnsAreaContainer> + ); + }; + +} + +export default @connect(mapStateToProps) @injectIntl @withRouter -export default class UI extends React.Component { +class UI extends React.Component { static propTypes = { dispatch: PropTypes.func.isRequired, @@ -119,6 +249,7 @@ export default class UI extends React.Component { isComposing: PropTypes.bool, hasComposingText: PropTypes.bool, hasMediaAttachments: PropTypes.bool, + canUploadMore: PropTypes.bool, match: PropTypes.object.isRequired, location: PropTypes.object.isRequired, history: PropTypes.object.isRequired, @@ -126,15 +257,17 @@ export default class UI extends React.Component { dropdownMenuIsOpen: PropTypes.bool, unreadNotifications: PropTypes.number, showFaviconBadge: PropTypes.bool, + moved: PropTypes.map, }; state = { - width: window.innerWidth, draggingOver: false, }; handleBeforeUnload = (e) => { - const { intl, hasComposingText, hasMediaAttachments } = this.props; + const { intl, dispatch, hasComposingText, hasMediaAttachments } = this.props; + + dispatch(submitMarkers()); if (hasComposingText || hasMediaAttachments) { // Setting returnValue to any string causes confirmation dialog. @@ -144,14 +277,10 @@ export default class UI extends React.Component { } } - handleResize = debounce(() => { + handleLayoutChange = () => { // The cached heights are no longer accurate, invalidate this.props.dispatch(clearHeight()); - - this.setState({ width: window.innerWidth }); - }, 500, { - trailing: true, - }); + } handleDragEnter = (e) => { e.preventDefault(); @@ -164,7 +293,7 @@ export default class UI extends React.Component { this.dragTargets.push(e.target); } - if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { + if (e.dataTransfer && e.dataTransfer.types.includes('Files') && this.props.canUploadMore) { this.setState({ draggingOver: true }); } } @@ -185,12 +314,13 @@ export default class UI extends React.Component { handleDrop = (e) => { if (this.dataTransferIsText(e.dataTransfer)) return; + e.preventDefault(); this.setState({ draggingOver: false }); this.dragTargets = []; - if (e.dataTransfer && e.dataTransfer.files.length >= 1) { + if (e.dataTransfer && e.dataTransfer.files.length >= 1 && this.props.canUploadMore) { this.props.dispatch(uploadCompose(e.dataTransfer.files)); } } @@ -209,7 +339,7 @@ export default class UI extends React.Component { } dataTransferIsText = (dataTransfer) => { - return (dataTransfer && Array.from(dataTransfer.types).includes('text/plain') && dataTransfer.items.length === 1); + return (dataTransfer && Array.from(dataTransfer.types).filter((type) => type === 'text/plain').length === 1); } closeUploadModal = () => { @@ -246,7 +376,6 @@ export default class UI extends React.Component { } window.addEventListener('beforeunload', this.handleBeforeUnload, false); - window.addEventListener('resize', this.handleResize, { passive: true }); document.addEventListener('dragenter', this.handleDragEnter, false); document.addEventListener('dragover', this.handleDragOver, false); document.addEventListener('drop', this.handleDrop, false); @@ -271,13 +400,14 @@ export default class UI extends React.Component { } componentDidUpdate (prevProps) { - if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) { - this.columnsAreaNode.handleChildrenContentChange(); - } if (this.props.unreadNotifications != prevProps.unreadNotifications || this.props.showFaviconBadge != prevProps.showFaviconBadge) { if (this.favicon) { - this.favicon.badge(this.props.showFaviconBadge ? this.props.unreadNotifications : 0); + try { + this.favicon.badge(this.props.showFaviconBadge ? this.props.unreadNotifications : 0); + } catch (err) { + console.error(err); + } } } } @@ -288,7 +418,6 @@ export default class UI extends React.Component { } window.removeEventListener('beforeunload', this.handleBeforeUnload); - window.removeEventListener('resize', this.handleResize); document.removeEventListener('dragenter', this.handleDragEnter); document.removeEventListener('dragover', this.handleDragOver); document.removeEventListener('drop', this.handleDrop); @@ -300,10 +429,6 @@ export default class UI extends React.Component { this.node = c; } - setColumnsAreaRef = c => { - this.columnsAreaNode = c.getWrappedInstance(); - } - handleHotkeyNew = e => { e.preventDefault(); @@ -317,7 +442,7 @@ export default class UI extends React.Component { handleHotkeySearch = e => { e.preventDefault(); - const element = this.node.querySelector('.drawer--search input'); + const element = this.node.querySelector('.search__input'); if (element) { element.focus(); @@ -417,10 +542,8 @@ export default class UI extends React.Component { } render () { - const { width, draggingOver } = this.state; - const { children, layout, isWide, navbarUnder, dropdownMenuIsOpen } = this.props; - const singleColumn = isMobile(width, layout); - const redirect = singleColumn ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />; + const { draggingOver } = this.state; + const { children, layout, isWide, navbarUnder, location, dropdownMenuIsOpen, moved } = this.props; const columnsClass = layout => { switch (layout) { @@ -464,45 +587,20 @@ export default class UI extends React.Component { return ( <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused> <div className={className} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}> - <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={singleColumn} navbarUnder={navbarUnder}> - <WrappedSwitch> - {redirect} - <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> - <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} /> - <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} /> - <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} /> - <WrappedRoute path='/timelines/public/local' exact component={CommunityTimeline} content={children} /> - <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} /> - <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> - <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} /> - <WrappedRoute path='/notifications' component={Notifications} content={children} /> - <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> - <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} /> - <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> - - <WrappedRoute path='/search' component={Search} content={children} /> - - <WrappedRoute path='/statuses/new' component={Compose} content={children} /> - <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> - <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} /> - <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} /> - - <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} /> - <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ withReplies: true }} /> - <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} /> - <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} /> - <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} /> - - <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} /> - <WrappedRoute path='/blocks' component={Blocks} content={children} /> - <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} /> - <WrappedRoute path='/mutes' component={Mutes} content={children} /> - <WrappedRoute path='/lists' component={Lists} content={children} /> - <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} /> - - <WrappedRoute component={GenericNotFound} content={children} /> - </WrappedSwitch> - </ColumnsAreaContainer> + {moved && (<div className='flash-message alert'> + <FormattedMessage + id='moved_to_warning' + defaultMessage='This account is marked as moved to {moved_to_link}, and may thus not accept new follows.' + values={{ moved_to_link: ( + <PermaLink href={moved.get('url')} to={`/accounts/${moved.get('id')}`}> + @{moved.get('acct')} + </PermaLink> + )}} + /> + </div>)} + <SwitchingColumnsArea location={location} layout={layout} navbarUnder={navbarUnder} onLayoutChange={this.handleLayoutChange}> + {children} + </SwitchingColumnsArea> <NotificationsContainer /> <LoadingBarContainer className='loading-bar' /> diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js index b73ea0b07..049baaee7 100644 --- a/app/javascript/flavours/glitch/features/video/index.js +++ b/app/javascript/flavours/glitch/features/video/index.js @@ -5,7 +5,8 @@ import { fromJS, is } from 'immutable'; import { throttle } from 'lodash'; import classNames from 'classnames'; import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen'; -import { displayMedia } from 'flavours/glitch/util/initial_state'; +import { displayMedia, useBlurhash } from 'flavours/glitch/util/initial_state'; +import Icon from 'flavours/glitch/components/icon'; import { decode } from 'blurhash'; const messages = defineMessages({ @@ -18,9 +19,10 @@ const messages = defineMessages({ close: { id: 'video.close', defaultMessage: 'Close video' }, fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' }, exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' }, + download: { id: 'video.download', defaultMessage: 'Download file' }, }); -const formatTime = secondsNum => { +export const formatTime = secondsNum => { let hours = Math.floor(secondsNum / 3600); let minutes = Math.floor((secondsNum - (hours * 3600)) / 60); let seconds = secondsNum - (hours * 3600) - (minutes * 60); @@ -84,8 +86,8 @@ export const getPointerPosition = (el, event) => { return position; }; -@injectIntl -export default class Video extends React.PureComponent { +export default @injectIntl +class Video extends React.PureComponent { static propTypes = { preview: PropTypes.string, @@ -101,6 +103,7 @@ export default class Video extends React.PureComponent { fullwidth: PropTypes.bool, detailed: PropTypes.bool, inline: PropTypes.bool, + editable: PropTypes.bool, cacheWidth: PropTypes.func, intl: PropTypes.object.isRequired, visible: PropTypes.bool, @@ -312,7 +315,7 @@ export default class Video extends React.PureComponent { } _decode () { - if (!this.canvas) return; + if (!this.canvas || !useBlurhash) return; const hash = this.props.blurhash; const pixels = decode(hash, 32, 32); @@ -393,7 +396,7 @@ export default class Video extends React.PureComponent { } render () { - const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link } = this.props; + const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable } = this.props; const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const progress = (currentTime / duration) * 100; const playerStyle = {}; @@ -401,7 +404,7 @@ export default class Video extends React.PureComponent { const volumeWidth = (muted) ? 0 : volume * this.volWidth; const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume); - const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth }); + const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth }); let { width, height } = this.props; @@ -443,7 +446,7 @@ export default class Video extends React.PureComponent { > <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} /> - {revealed && <video + {(revealed || editable) && <video ref={this.setVideoRef} src={src} poster={preview} @@ -465,7 +468,7 @@ export default class Video extends React.PureComponent { onVolumeChange={this.handleVolumeChange} />} - <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}> + <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}> <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}> <span className='spoiler-button__overlay__label'>{warning}</span> </button> @@ -485,9 +488,10 @@ export default class Video extends React.PureComponent { <div className='video-player__buttons-bar'> <div className='video-player__buttons left'> - <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><i className={classNames('fa fa-fw', { 'fa-play': paused, 'fa-pause': !paused })} /></button> - <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><i className={classNames('fa fa-fw', { 'fa-volume-off': muted, 'fa-volume-up': !muted })} /></button> + <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> + <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}> + <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} /> <span className={classNames('video-player__volume__handle')} @@ -508,10 +512,16 @@ export default class Video extends React.PureComponent { </div> <div className='video-player__buttons right'> - {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye-slash' /></button>} - {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>} - {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-compress' /></button>} - <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button> + {(!onCloseVideo && !editable) && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} + {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>} + {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>} + <button type='button' aria-label={intl.formatMessage(messages.download)}> + <a className='video-player__download__icon' href={this.props.src} download> + <Icon id={'download'} fixedWidth /> + </a> + </button> + <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button> + </div> </div> </div> diff --git a/app/javascript/flavours/glitch/locales/pt.js b/app/javascript/flavours/glitch/locales/pt-PT.js index 0156f55ff..cf7afd17a 100644 --- a/app/javascript/flavours/glitch/locales/pt.js +++ b/app/javascript/flavours/glitch/locales/pt-PT.js @@ -1,4 +1,4 @@ -import inherited from 'mastodon/locales/pt.json'; +import inherited from 'mastodon/locales/pt-PT.json'; const messages = { // No translations available. diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js index da0b4c8e0..973d6ee46 100644 --- a/app/javascript/flavours/glitch/packs/public.js +++ b/app/javascript/flavours/glitch/packs/public.js @@ -1,19 +1,21 @@ import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import ready from 'flavours/glitch/util/ready'; +import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions'; function main() { const IntlMessageFormat = require('intl-messageformat').default; const { timeAgoString } = require('flavours/glitch/components/relative_timestamp'); + const { delegate } = require('rails-ujs'); const emojify = require('flavours/glitch/util/emoji').default; const { getLocale } = require('locales'); const { messages } = getLocale(); const React = require('react'); const ReactDOM = require('react-dom'); const Rellax = require('rellax'); - const createHistory = require('history').createBrowserHistory; + const { createBrowserHistory } = require('history'); const scrollToDetailedStatus = () => { - const history = createHistory(); + const history = createBrowserHistory(); const detailedStatuses = document.querySelectorAll('.public-layout .detailed-status'); const location = history.location; @@ -23,6 +25,12 @@ function main() { } }; + const getEmojiAnimationHandler = (swapTo) => { + return ({ target }) => { + target.src = target.getAttribute(swapTo); + }; + }; + ready(() => { const locale = document.documentElement.lang; @@ -87,16 +95,33 @@ function main() { new Rellax('.parallax', { speed: -1 }); } - if (document.body.classList.contains('with-modals')) { - const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - const scrollbarWidthStyle = document.createElement('style'); - scrollbarWidthStyle.id = 'scrollbar-width'; - document.head.appendChild(scrollbarWidthStyle); - scrollbarWidthStyle.sheet.insertRule(`body.with-modals--active { margin-right: ${scrollbarWidth}px; }`, 0); + delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original')); + delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static')); + + delegate(document, '.blocks-table button.icon-button', 'click', function(e) { + e.preventDefault(); + + const classList = this.firstElementChild.classList; + classList.toggle('fa-chevron-down'); + classList.toggle('fa-chevron-up'); + this.parentElement.parentElement.nextElementSibling.classList.toggle('hidden'); + }); + }); + + delegate(document, '.sidebar__toggle__icon', 'click', () => { + const target = document.querySelector('.sidebar ul'); + + if (target.style.display === 'block') { + target.style.display = 'none'; + } else { + target.style.display = 'block'; } }); } -loadPolyfills().then(main).catch(error => { - console.error(error); -}); +loadPolyfills() + .then(main) + .then(loadKeyboardExtensions) + .catch(error => { + console.error(error); + }); diff --git a/app/javascript/flavours/glitch/packs/settings.js b/app/javascript/flavours/glitch/packs/settings.js new file mode 100644 index 000000000..edf1b82e0 --- /dev/null +++ b/app/javascript/flavours/glitch/packs/settings.js @@ -0,0 +1,24 @@ +import loadPolyfills from 'flavours/glitch/util/load_polyfills'; +import ready from 'flavours/glitch/util/ready'; +import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions'; + +function main() { + const { delegate } = require('rails-ujs'); + + delegate(document, '.sidebar__toggle__icon', 'click', () => { + const target = document.querySelector('.sidebar ul'); + + if (target.style.display === 'block') { + target.style.display = 'none'; + } else { + target.style.display = 'block'; + } + }); +} + +loadPolyfills() + .then(main) + .then(loadKeyboardExtensions) + .catch(error => { + console.error(error); + }); diff --git a/app/javascript/flavours/glitch/reducers/alerts.js b/app/javascript/flavours/glitch/reducers/alerts.js index 50f8d30f7..ee3d54ab0 100644 --- a/app/javascript/flavours/glitch/reducers/alerts.js +++ b/app/javascript/flavours/glitch/reducers/alerts.js @@ -14,6 +14,7 @@ export default function alerts(state = initialState, action) { key: state.size > 0 ? state.last().get('key') + 1 : 0, title: action.title, message: action.message, + message_values: action.message_values, })); case ALERT_DISMISS: return state.filterNot(item => item.get('key') === action.alert.key); diff --git a/app/javascript/flavours/glitch/reducers/blocks.js b/app/javascript/flavours/glitch/reducers/blocks.js new file mode 100644 index 000000000..1b6507163 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/blocks.js @@ -0,0 +1,22 @@ +import Immutable from 'immutable'; + +import { + BLOCKS_INIT_MODAL, +} from '../actions/blocks'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + account_id: null, + }), +}); + +export default function mutes(state = initialState, action) { + switch (action.type) { + case BLOCKS_INIT_MODAL: + return state.withMutations((state) => { + state.setIn(['new', 'account_id'], action.account.get('id')); + }); + default: + return state; + } +} diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index 36dfb8f15..0f807790b 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -78,6 +78,7 @@ const initialState = ImmutableMap({ is_changing_upload: false, progress: 0, media_attachments: ImmutableList(), + pending_media_attachments: 0, poll: null, suggestion_token: null, suggestions: ImmutableList(), @@ -182,6 +183,7 @@ function continueThread (state, status) { map.set('privacy', status.visibility); map.set('sensitive', false); map.update('media_attachments', list => list.clear()); + map.set('poll', null); map.set('idempotencyKey', uuid()); map.set('focusDate', new Date()); map.set('caretPosition', null); @@ -189,14 +191,18 @@ function continueThread (state, status) { }); } -function appendMedia(state, media) { +function appendMedia(state, media, file) { const prevSize = state.get('media_attachments').size; return state.withMutations(map => { + if (media.get('type') === 'image') { + media = media.set('file', file); + } map.update('media_attachments', list => list.push(media)); map.set('is_uploading', false); map.set('resetFileKey', Math.floor((Math.random() * 0x10000))); map.set('idempotencyKey', uuid()); + map.update('pending_media_attachments', n => n - 1); if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) { map.set('sensitive', true); @@ -230,15 +236,20 @@ const insertSuggestion = (state, position, token, completion, path) => { }); }; -const updateSuggestionTags = (state, token) => { - const prefix = token.slice(1); +const sortHashtagsByUse = (state, tags) => { + const personalHistory = state.get('tagHistory'); - return state.merge({ - suggestions: state.get('tagHistory') - .filter(tag => tag.toLowerCase().startsWith(prefix.toLowerCase())) - .slice(0, 4) - .map(tag => '#' + tag), - suggestion_token: token, + return tags.sort((a, b) => { + const usedA = personalHistory.includes(a.name); + const usedB = personalHistory.includes(b.name); + + if (usedA === usedB) { + return 0; + } else if (usedA && !usedB) { + return 1; + } else { + return -1; + } }); }; @@ -275,6 +286,42 @@ const expandMentions = status => { return fragment.innerHTML; }; +const expiresInFromExpiresAt = expires_at => { + if (!expires_at) return 24 * 3600; + const delta = (new Date(expires_at).getTime() - Date.now()) / 1000; + return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600; +}; + +const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => { + prefix = prefix.toLowerCase(); + if (suggestions.length < 4) { + const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase())); + return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag }))); + } else { + return suggestions; + } +}; + +const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => { + if (accounts) { + return accounts.map(item => ({ id: item.id, type: 'account' })); + } else if (emojis) { + return emojis.map(item => ({ ...item, type: 'emoji' })); + } else { + return mergeLocalHashtagResults(sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' }))), token.slice(1), state.get('tagHistory')); + } +}; + +const updateSuggestionTags = (state, token) => { + const prefix = token.slice(1); + + const suggestions = state.get('suggestions').toJS(); + return state.merge({ + suggestions: ImmutableList(mergeLocalHashtagResults(suggestions, prefix, state.get('tagHistory'))), + suggestion_token: token, + }); +}; + export default function compose(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: @@ -378,11 +425,11 @@ export default function compose(state = initialState, action) { case COMPOSE_UPLOAD_CHANGE_FAIL: return state.set('is_changing_upload', false); case COMPOSE_UPLOAD_REQUEST: - return state.set('is_uploading', true); + return state.set('is_uploading', true).update('pending_media_attachments', n => n + 1); case COMPOSE_UPLOAD_SUCCESS: - return appendMedia(state, fromJS(action.media)); + return appendMedia(state, fromJS(action.media), action.file); case COMPOSE_UPLOAD_FAIL: - return state.set('is_uploading', false); + return state.set('is_uploading', false).update('pending_media_attachments', n => n - 1); case COMPOSE_UPLOAD_UNDO: return removeMedia(state, action.media_id); case COMPOSE_UPLOAD_PROGRESS: @@ -405,7 +452,7 @@ export default function compose(state = initialState, action) { case COMPOSE_SUGGESTIONS_CLEAR: return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null); case COMPOSE_SUGGESTIONS_READY: - return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token); + return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token); case COMPOSE_SUGGESTION_SELECT: return insertSuggestion(state, action.position, action.token, action.completion, action.path); case COMPOSE_SUGGESTION_TAGS_UPDATE: @@ -456,7 +503,7 @@ export default function compose(state = initialState, action) { map.set('poll', ImmutableMap({ options: action.status.getIn(['poll', 'options']).map(x => x.get('title')), multiple: action.status.getIn(['poll', 'multiple']), - expires_in: 24 * 3600, + expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])), })); } }); diff --git a/app/javascript/flavours/glitch/reducers/conversations.js b/app/javascript/flavours/glitch/reducers/conversations.js index c01659da5..fba0308bc 100644 --- a/app/javascript/flavours/glitch/reducers/conversations.js +++ b/app/javascript/flavours/glitch/reducers/conversations.js @@ -7,7 +7,10 @@ import { CONVERSATIONS_FETCH_FAIL, CONVERSATIONS_UPDATE, CONVERSATIONS_READ, + CONVERSATIONS_DELETE_SUCCESS, } from '../actions/conversations'; +import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts'; +import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks'; import compareId from 'flavours/glitch/util/compare_id'; const initialState = ImmutableMap({ @@ -74,6 +77,10 @@ const expandNormalizedConversations = (state, conversations, next, isLoadingRece }); }; +const filterConversations = (state, accountIds) => { + return state.update('items', list => list.filterNot(item => item.get('accounts').some(accountId => accountIds.includes(accountId)))); +}; + export default function conversations(state = initialState, action) { switch (action.type) { case CONVERSATIONS_FETCH_REQUEST: @@ -96,6 +103,13 @@ export default function conversations(state = initialState, action) { return item; })); + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return filterConversations(state, [action.relationship.id]); + case DOMAIN_BLOCK_SUCCESS: + return filterConversations(state, action.accounts); + case CONVERSATIONS_DELETE_SUCCESS: + return state.update('items', list => list.filterNot(item => item.get('id') === action.id)); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js index 266d87dc1..7dbca3a29 100644 --- a/app/javascript/flavours/glitch/reducers/index.js +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -16,6 +16,7 @@ import local_settings from './local_settings'; import push_notifications from './push_notifications'; import status_lists from './status_lists'; import mutes from './mutes'; +import blocks from './blocks'; import reports from './reports'; import contexts from './contexts'; import compose from './compose'; @@ -33,6 +34,7 @@ import suggestions from './suggestions'; import pinnedAccountsEditor from './pinned_accounts_editor'; import polls from './polls'; import identity_proofs from './identity_proofs'; +import trends from './trends'; const reducers = { dropdown_menu, @@ -52,6 +54,7 @@ const reducers = { local_settings, push_notifications, mutes, + blocks, reports, contexts, compose, @@ -69,6 +72,7 @@ const reducers = { suggestions, pinnedAccountsEditor, polls, + trends, }; export default combineReducers(reducers); diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js index 68e1c8424..ad94ea243 100644 --- a/app/javascript/flavours/glitch/reducers/local_settings.js +++ b/app/javascript/flavours/glitch/reducers/local_settings.js @@ -21,6 +21,9 @@ const initialState = ImmutableMap({ inline_preview_cards: true, hicolor_privacy_icons: false, show_content_type_choice: false, + filtering_behavior: 'hide', + tag_misleading_links: true, + rewrite_mentions: 'no', content_warnings : ImmutableMap({ auto_unfold : false, filter : null, diff --git a/app/javascript/flavours/glitch/reducers/modal.js b/app/javascript/flavours/glitch/reducers/modal.js index 80bc11dda..7bd9d4b32 100644 --- a/app/javascript/flavours/glitch/reducers/modal.js +++ b/app/javascript/flavours/glitch/reducers/modal.js @@ -10,7 +10,7 @@ export default function modal(state = initialState, action) { case MODAL_OPEN: return { modalType: action.modalType, modalProps: action.modalProps }; case MODAL_CLOSE: - return initialState; + return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state; default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js index 8f52a7704..7111bb710 100644 --- a/app/javascript/flavours/glitch/reducers/mutes.js +++ b/app/javascript/flavours/glitch/reducers/mutes.js @@ -7,7 +7,6 @@ import { const initialState = Immutable.Map({ new: Immutable.Map({ - isSubmitting: false, account: null, notifications: true, }), @@ -17,7 +16,6 @@ export default function mutes(state = initialState, action) { switch (action.type) { case MUTES_INIT_MODAL: return state.withMutations((state) => { - state.setIn(['new', 'isSubmitting'], false); state.setIn(['new', 'account'], action.account); state.setIn(['new', 'notifications'], true); }); diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js index 5bbf9c822..3623e90da 100644 --- a/app/javascript/flavours/glitch/reducers/notifications.js +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -9,6 +9,7 @@ import { NOTIFICATIONS_FILTER_SET, NOTIFICATIONS_CLEAR, NOTIFICATIONS_SCROLL_TOP, + NOTIFICATIONS_LOAD_PENDING, NOTIFICATIONS_DELETE_MARKED_REQUEST, NOTIFICATIONS_DELETE_MARKED_SUCCESS, NOTIFICATION_MARK_FOR_DELETE, @@ -19,12 +20,16 @@ import { import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS, + FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + FOLLOW_REQUEST_REJECT_SUCCESS, } from 'flavours/glitch/actions/accounts'; +import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks'; import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import compareId from 'flavours/glitch/util/compare_id'; const initialState = ImmutableMap({ + pendingItems: ImmutableList(), items: ImmutableList(), hasMore: true, top: false, @@ -46,9 +51,13 @@ const notificationToMap = (state, notification) => ImmutableMap({ status: notification.status ? notification.status.id : null, }); -const normalizeNotification = (state, notification) => { +const normalizeNotification = (state, notification, usePendingItems) => { const top = !shouldCountUnreadNotifications(state); + if (usePendingItems || !state.get('pendingItems').isEmpty()) { + return state.update('pendingItems', list => list.unshift(notificationToMap(state, notification))).update('unread', unread => unread + 1); + } + if (top) { state = state.set('lastReadId', notification.id); } else { @@ -64,7 +73,7 @@ const normalizeNotification = (state, notification) => { }); }; -const expandNormalizedNotifications = (state, notifications, next) => { +const expandNormalizedNotifications = (state, notifications, next, isLoadingRecent, usePendingItems) => { const top = !(shouldCountUnreadNotifications(state)); const lastReadId = state.get('lastReadId'); let items = ImmutableList(); @@ -75,7 +84,9 @@ const expandNormalizedNotifications = (state, notifications, next) => { return state.withMutations(mutable => { if (!items.isEmpty()) { - mutable.update('items', list => { + usePendingItems = isLoadingRecent && (usePendingItems || !mutable.get('pendingItems').isEmpty()); + + mutable.update(usePendingItems ? 'pendingItems' : 'items', list => { const lastIndex = 1 + list.findLastIndex( item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id')) ); @@ -104,12 +115,13 @@ const expandNormalizedNotifications = (state, notifications, next) => { }); }; -const filterNotifications = (state, relationship) => { - return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id)); +const filterNotifications = (state, accountIds, type) => { + const helper = list => list.filterNot(item => item !== null && accountIds.includes(item.get('account')) && (type === undefined || type === item.get('type'))); + return state.update('items', helper).update('pendingItems', helper); }; const clearUnread = (state) => { - state = state.set('unread', 0); + state = state.set('unread', state.get('pendingItems').size); const lastNotification = state.get('items').find(item => item !== null); return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0'); } @@ -131,7 +143,10 @@ const deleteByStatus = (state, statusId) => { const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0); state = state.update('unread', unread => unread - deletedUnread.size); } - return state.update('items', list => list.filterNot(item => item !== null && item.get('status') === statusId)); + const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId); + const deletedUnread = state.get('pendingItems').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0); + state = state.update('unread', unread => unread - deletedUnread.size); + return state.update('items', helper).update('pendingItems', helper); }; const markForDelete = (state, notificationId, yes) => { @@ -192,6 +207,8 @@ export default function notifications(state = initialState, action) { return state.update('mounted', count => count - 1); case NOTIFICATIONS_SET_VISIBILITY: return updateVisibility(state, action.visibility); + case NOTIFICATIONS_LOAD_PENDING: + return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0); case NOTIFICATIONS_EXPAND_REQUEST: case NOTIFICATIONS_DELETE_MARKED_REQUEST: return state.set('isLoading', true); @@ -203,20 +220,27 @@ export default function notifications(state = initialState, action) { case NOTIFICATIONS_SCROLL_TOP: return updateTop(state, action.top); case NOTIFICATIONS_UPDATE: - return normalizeNotification(state, action.notification); + return normalizeNotification(state, action.notification, action.usePendingItems); case NOTIFICATIONS_EXPAND_SUCCESS: - return expandNormalizedNotifications(state, action.notifications, action.next); + return expandNormalizedNotifications(state, action.notifications, action.next, action.isLoadingRecent, action.usePendingItems); case ACCOUNT_BLOCK_SUCCESS: - return filterNotifications(state, action.relationship); + return filterNotifications(state, [action.relationship.id]); + case ACCOUNT_MUTE_SUCCESS: + return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state; + case DOMAIN_BLOCK_SUCCESS: + return filterNotifications(state, action.accounts); + case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: + case FOLLOW_REQUEST_REJECT_SUCCESS: + return filterNotifications(state, [action.id], 'follow_request'); case ACCOUNT_MUTE_SUCCESS: - return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state; + return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state; case NOTIFICATIONS_CLEAR: - return state.set('items', ImmutableList()).set('hasMore', false); + return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false); case TIMELINE_DELETE: return deleteByStatus(state, action.id); case TIMELINE_DISCONNECT: return action.timeline === 'home' ? - state.update('items', items => items.first() ? items.unshift(null) : items) : + state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) : state; case NOTIFICATION_MARK_FOR_DELETE: diff --git a/app/javascript/flavours/glitch/reducers/polls.js b/app/javascript/flavours/glitch/reducers/polls.js index 9956cf83f..595f340bc 100644 --- a/app/javascript/flavours/glitch/reducers/polls.js +++ b/app/javascript/flavours/glitch/reducers/polls.js @@ -1,4 +1,4 @@ -import { POLLS_IMPORT } from 'mastodon/actions/importer'; +import { POLLS_IMPORT } from 'flavours/glitch/actions/importer'; import { Map as ImmutableMap, fromJS } from 'immutable'; const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); diff --git a/app/javascript/flavours/glitch/reducers/push_notifications.js b/app/javascript/flavours/glitch/reducers/push_notifications.js index e87e8fc1a..117fb5167 100644 --- a/app/javascript/flavours/glitch/reducers/push_notifications.js +++ b/app/javascript/flavours/glitch/reducers/push_notifications.js @@ -6,6 +6,7 @@ const initialState = Immutable.Map({ subscription: null, alerts: new Immutable.Map({ follow: false, + follow_request: false, favourite: false, reblog: false, mention: false, diff --git a/app/javascript/flavours/glitch/reducers/reports.js b/app/javascript/flavours/glitch/reducers/reports.js index fdcfb14a0..1f7f3f273 100644 --- a/app/javascript/flavours/glitch/reducers/reports.js +++ b/app/javascript/flavours/glitch/reducers/reports.js @@ -8,6 +8,9 @@ import { REPORT_COMMENT_CHANGE, REPORT_FORWARD_CHANGE, } from 'flavours/glitch/actions/reports'; +import { + TIMELINE_DELETE, +} from 'flavours/glitch/actions/timelines'; import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable'; const initialState = ImmutableMap({ @@ -20,6 +23,14 @@ const initialState = ImmutableMap({ }), }); +const deleteStatus = (state, id, references) => { + references.forEach(ref => { + state = deleteStatus(state, ref[0], []); + }); + + return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.remove(id)); +}; + export default function reports(state = initialState, action) { switch(action.type) { case REPORT_INIT: @@ -58,6 +69,8 @@ export default function reports(state = initialState, action) { map.setIn(['new', 'comment'], ''); map.setIn(['new', 'isSubmitting'], false); }); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.references); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/search.js b/app/javascript/flavours/glitch/reducers/search.js index 9a525bf47..c346e958b 100644 --- a/app/javascript/flavours/glitch/reducers/search.js +++ b/app/javascript/flavours/glitch/reducers/search.js @@ -3,6 +3,7 @@ import { SEARCH_CLEAR, SEARCH_FETCH_SUCCESS, SEARCH_SHOW, + SEARCH_EXPAND_SUCCESS, } from 'flavours/glitch/actions/search'; import { COMPOSE_MENTION, @@ -16,6 +17,7 @@ const initialState = ImmutableMap({ submitted: false, hidden: false, results: ImmutableMap(), + searchTerm: '', }); export default function search(state = initialState, action) { @@ -40,7 +42,10 @@ export default function search(state = initialState, action) { accounts: ImmutableList(action.results.accounts.map(item => item.id)), statuses: ImmutableList(action.results.statuses.map(item => item.id)), hashtags: fromJS(action.results.hashtags), - })).set('submitted', true); + })).set('submitted', true).set('searchTerm', action.searchTerm); + case SEARCH_EXPAND_SUCCESS: + const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id); + return state.updateIn(['results', action.searchType], list => list.concat(results)); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js index a37863a69..ef99ad552 100644 --- a/app/javascript/flavours/glitch/reducers/settings.js +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -15,6 +15,10 @@ const initialState = ImmutableMap({ skinTone: 1, + trends: ImmutableMap({ + show: true, + }), + home: ImmutableMap({ shows: ImmutableMap({ reblog: true, @@ -30,6 +34,7 @@ const initialState = ImmutableMap({ notifications: ImmutableMap({ alerts: ImmutableMap({ follow: true, + follow_request: false, favourite: true, reblog: true, mention: true, @@ -44,6 +49,7 @@ const initialState = ImmutableMap({ shows: ImmutableMap({ follow: true, + follow_request: false, favourite: true, reblog: true, mention: true, @@ -52,6 +58,7 @@ const initialState = ImmutableMap({ sounds: ImmutableMap({ follow: true, + follow_request: false, favourite: true, reblog: true, mention: true, diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js index 96c9c6d04..ee8ac929d 100644 --- a/app/javascript/flavours/glitch/reducers/statuses.js +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -3,6 +3,7 @@ import { REBLOG_FAIL, FAVOURITE_REQUEST, FAVOURITE_FAIL, + UNFAVOURITE_SUCCESS, BOOKMARK_REQUEST, BOOKMARK_FAIL, } from 'flavours/glitch/actions/interactions'; @@ -39,6 +40,9 @@ export default function statuses(state = initialState, action) { return importStatuses(state, action.statuses); case FAVOURITE_REQUEST: return state.setIn([action.status.get('id'), 'favourited'], true); + case UNFAVOURITE_SUCCESS: + const favouritesCount = action.status.get('favourites_count'); + return state.setIn([action.status.get('id'), 'favourites_count'], favouritesCount - 1); case FAVOURITE_FAIL: return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false); case BOOKMARK_REQUEST: diff --git a/app/javascript/flavours/glitch/reducers/suggestions.js b/app/javascript/flavours/glitch/reducers/suggestions.js index 9f4b89d58..a08fedc25 100644 --- a/app/javascript/flavours/glitch/reducers/suggestions.js +++ b/app/javascript/flavours/glitch/reducers/suggestions.js @@ -4,6 +4,8 @@ import { SUGGESTIONS_FETCH_FAIL, SUGGESTIONS_DISMISS, } from '../actions/suggestions'; +import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts'; +import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; const initialState = ImmutableMap({ @@ -24,6 +26,11 @@ export default function suggestionsReducer(state = initialState, action) { return state.set('isLoading', false); case SUGGESTIONS_DISMISS: return state.update('items', list => list.filterNot(id => id === action.id)); + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return state.update('items', list => list.filterNot(id => id === action.relationship.id)); + case DOMAIN_BLOCK_SUCCESS: + return state.update('items', list => list.filterNot(id => action.accounts.includes(id))); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js index 440b370e6..d3318f8d3 100644 --- a/app/javascript/flavours/glitch/reducers/timelines.js +++ b/app/javascript/flavours/glitch/reducers/timelines.js @@ -8,6 +8,7 @@ import { TIMELINE_SCROLL_TOP, TIMELINE_CONNECT, TIMELINE_DISCONNECT, + TIMELINE_LOAD_PENDING, } from 'flavours/glitch/actions/timelines'; import { ACCOUNT_BLOCK_SUCCESS, @@ -25,10 +26,11 @@ const initialTimeline = ImmutableMap({ top: true, isLoading: false, hasMore: true, + pendingItems: ImmutableList(), items: ImmutableList(), }); -const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent) => { +const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => { return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { mMap.set('isLoading', false); mMap.set('isPartial', isPartial); @@ -38,7 +40,9 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is if (timeline.endsWith(':pinned')) { mMap.set('items', statuses.map(status => status.get('id'))); } else if (!statuses.isEmpty()) { - mMap.update('items', ImmutableList(), oldIds => { + usePendingItems = isLoadingRecent && (usePendingItems || !mMap.get('pendingItems').isEmpty()); + + mMap.update(usePendingItems ? 'pendingItems' : 'items', ImmutableList(), oldIds => { const newIds = statuses.map(status => status.get('id')); const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1; const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0); @@ -56,8 +60,23 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is })); }; -const updateTimeline = (state, timeline, status) => { - const top = state.getIn([timeline, 'top']); +const updateTimeline = (state, timeline, status, usePendingItems, filtered) => { + const top = state.getIn([timeline, 'top']); + + if (usePendingItems || !state.getIn([timeline, 'pendingItems']).isEmpty()) { + if (state.getIn([timeline, 'pendingItems'], ImmutableList()).includes(status.get('id')) || state.getIn([timeline, 'items'], ImmutableList()).includes(status.get('id'))) { + return state; + } + + state = state.update(timeline, initialTimeline, map => map.update('pendingItems', list => list.unshift(status.get('id')))); + + if (!filtered) { + state = state.update('unread', unread => unread + 1); + } + + return state; + } + const ids = state.getIn([timeline, 'items'], ImmutableList()); const includesId = ids.includes(status.get('id')); const unread = state.getIn([timeline, 'unread'], 0); @@ -69,7 +88,7 @@ const updateTimeline = (state, timeline, status) => { let newIds = ids; return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { - if (!top) mMap.set('unread', unread + 1); + if (!top && !filtered) mMap.set('unread', unread + 1); if (top && ids.size > 40) newIds = newIds.take(20); mMap.set('items', newIds.unshift(status.get('id'))); })); @@ -77,8 +96,10 @@ const updateTimeline = (state, timeline, status) => { const deleteStatus = (state, id, accountId, references, exclude_account = null) => { state.keySeq().forEach(timeline => { - if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) - state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); + if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) { + const helper = list => list.filterNot(item => item === id); + state = state.updateIn([timeline, 'items'], helper).updateIn([timeline, 'pendingItems'], helper); + } }); // Remove reblogs of deleted status @@ -108,29 +129,31 @@ const filterTimelines = (state, relationship, statuses) => { return state; }; -const filterTimeline = (timeline, state, relationship, statuses) => - state.updateIn([timeline, 'items'], ImmutableList(), list => - list.filterNot(statusId => - statuses.getIn([statusId, 'account']) === relationship.id - )); +const filterTimeline = (timeline, state, relationship, statuses) => { + const helper = list => list.filterNot(statusId => statuses.getIn([statusId, 'account']) === relationship.id); + return state.updateIn([timeline, 'items'], ImmutableList(), helper).updateIn([timeline, 'pendingItems'], ImmutableList(), helper); +}; const updateTop = (state, timeline, top) => { return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { - if (top) mMap.set('unread', 0); + if (top) mMap.set('unread', mMap.get('pendingItems').size); mMap.set('top', top); })); }; export default function timelines(state = initialState, action) { switch(action.type) { + case TIMELINE_LOAD_PENDING: + return state.update(action.timeline, initialTimeline, map => + map.update('items', list => map.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0)); case TIMELINE_EXPAND_REQUEST: return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true)); case TIMELINE_EXPAND_FAIL: return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false)); case TIMELINE_EXPAND_SUCCESS: - return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent); + return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent, action.usePendingItems); case TIMELINE_UPDATE: - return updateTimeline(state, action.timeline, fromJS(action.status)); + return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems, action.filtered); case TIMELINE_DELETE: return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf); case TIMELINE_CLEAR: @@ -148,7 +171,7 @@ export default function timelines(state = initialState, action) { return state.update( action.timeline, initialTimeline, - map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items) + map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) ); default: return state; diff --git a/app/javascript/flavours/glitch/reducers/trends.js b/app/javascript/flavours/glitch/reducers/trends.js new file mode 100644 index 000000000..5cecc8fca --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/trends.js @@ -0,0 +1,23 @@ +import { TRENDS_FETCH_REQUEST, TRENDS_FETCH_SUCCESS, TRENDS_FETCH_FAIL } from '../actions/trends'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, +}); + +export default function trendsReducer(state = initialState, action) { + switch(action.type) { + case TRENDS_FETCH_REQUEST: + return state.set('isLoading', true); + case TRENDS_FETCH_SUCCESS: + return state.withMutations(map => { + map.set('items', fromJS(action.trends)); + map.set('isLoading', false); + }); + case TRENDS_FETCH_FAIL: + return state.set('isLoading', false); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js index a4df9ec8d..c8a6f524e 100644 --- a/app/javascript/flavours/glitch/reducers/user_lists.js +++ b/app/javascript/flavours/glitch/reducers/user_lists.js @@ -1,4 +1,7 @@ import { + NOTIFICATIONS_UPDATE, +} from '../actions/notifications'; +import { FOLLOWERS_FETCH_SUCCESS, FOLLOWERS_EXPAND_SUCCESS, FOLLOWING_FETCH_SUCCESS, @@ -20,6 +23,14 @@ import { MUTES_FETCH_SUCCESS, MUTES_EXPAND_SUCCESS, } from 'flavours/glitch/actions/mutes'; +import { + DIRECTORY_FETCH_REQUEST, + DIRECTORY_FETCH_SUCCESS, + DIRECTORY_FETCH_FAIL, + DIRECTORY_EXPAND_REQUEST, + DIRECTORY_EXPAND_SUCCESS, + DIRECTORY_EXPAND_FAIL, +} from 'flavours/glitch/actions/directory'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; const initialState = ImmutableMap({ @@ -45,6 +56,12 @@ const appendToList = (state, type, id, accounts, next) => { }); }; +const normalizeFollowRequest = (state, notification) => { + return state.updateIn(['follow_requests', 'items'], list => { + return list.filterNot(item => item === notification.account.id).unshift(notification.account.id); + }); +}; + export default function userLists(state = initialState, action) { switch(action.type) { case FOLLOWERS_FETCH_SUCCESS: @@ -59,6 +76,8 @@ export default function userLists(state = initialState, action) { 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 NOTIFICATIONS_UPDATE: + return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state; 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: @@ -74,6 +93,16 @@ export default function userLists(state = initialState, action) { 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); + case DIRECTORY_FETCH_SUCCESS: + return state.setIn(['directory', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false); + case DIRECTORY_EXPAND_SUCCESS: + return state.updateIn(['directory', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false); + case DIRECTORY_FETCH_REQUEST: + case DIRECTORY_EXPAND_REQUEST: + return state.setIn(['directory', 'isLoading'], true); + case DIRECTORY_FETCH_FAIL: + case DIRECTORY_EXPAND_FAIL: + return state.setIn(['directory', 'isLoading'], false); default: return state; } diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js index d1a88a2fc..8ceb71d03 100644 --- a/app/javascript/flavours/glitch/selectors/index.js +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -1,5 +1,6 @@ +import escapeTextContentForBrowser from 'escape-html'; import { createSelector } from 'reselect'; -import { List as ImmutableList } from 'immutable'; +import { List as ImmutableList, is } from 'immutable'; import { me } from 'flavours/glitch/util/initial_state'; const getAccountBase = (state, id) => state.getIn(['accounts', id], null); @@ -20,7 +21,7 @@ export const makeGetAccount = () => { }); }; -const toServerSideType = columnType => { +export const toServerSideType = columnType => { switch (columnType) { case 'home': case 'notifications': @@ -36,8 +37,6 @@ const toServerSideType = columnType => { } }; -export const getFilters = (state, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))); - const escapeRegExp = string => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string @@ -63,6 +62,27 @@ export const regexFromFilters = filters => { }).join('|'), 'i'); }; +// Memoize the filter regexps for each valid server contextType +const makeGetFiltersRegex = () => { + let memo = {}; + + return (state, { contextType }) => { + if (!contextType) return ImmutableList(); + + const serverSideType = toServerSideType(contextType); + const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))); + + if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) { + const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible'))); + const regex = regexFromFilters(filters); + memo[serverSideType] = { filters: filters, results: [dropRegex, regex] }; + } + return memo[serverSideType].results; + }; +}; + +export const getFiltersRegex = makeGetFiltersRegex(); + export const makeGetStatus = () => { return createSelector( [ @@ -70,15 +90,24 @@ export const makeGetStatus = () => { (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'])]), - getFilters, + (state, _) => state.getIn(['local_settings', 'filtering_behavior']), + (state, _) => state.get('filters', ImmutableList()), + (_, { contextType }) => contextType, + getFiltersRegex, ], - (statusBase, statusReblog, accountBase, accountReblog, filters) => { + (statusBase, statusReblog, accountBase, accountReblog, filteringBehavior, filters, contextType, filtersRegex) => { if (!statusBase) { return null; } - const regex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters); + const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0]; + + if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) { + return null; + } + + const regex = (accountReblog || accountBase).get('id') !== me && filtersRegex[1]; let filtered = false; if (statusReblog) { @@ -91,6 +120,26 @@ export const makeGetStatus = () => { filtered = filtered || regex && regex.test(statusBase.get('search_index')); + if (filtered && filteringBehavior === 'drop') { + return null; + } else if (filtered && filteringBehavior === 'content_warning') { + let spoilerText = (statusReblog || statusBase).get('spoiler_text', ''); + const searchIndex = (statusReblog || statusBase).get('search_index'); + const serverSideType = toServerSideType(contextType); + const enabledFilters = filters.filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))).toArray(); + const matchingFilters = enabledFilters.filter(filter => { + const regexp = regexFromFilters([filter]); + return regexp.test(searchIndex) && !regexp.test(spoilerText); + }); + if (statusReblog) { + statusReblog = statusReblog.set('spoiler_text', matchingFilters.map(filter => filter.get('phrase')).concat([spoilerText]).filter(cw => !!cw).join(', ')); + statusReblog = statusReblog.update('spoilerHtml', '', spoilerText => matchingFilters.map(filter => escapeTextContentForBrowser(filter.get('phrase'))).concat([spoilerText]).filter(cw => !!cw).join(', ')); + } else { + statusBase = statusBase.set('spoiler_text', matchingFilters.map(filter => filter.get('phrase')).concat([spoilerText]).filter(cw => !!cw).join(', ')); + statusBase = statusBase.update('spoilerHtml', '', spoilerText => matchingFilters.map(filter => escapeTextContentForBrowser(filter.get('phrase'))).concat([spoilerText]).filter(cw => !!cw).join(', ')); + } + } + return statusBase.withMutations(map => { map.set('reblog', statusReblog); map.set('account', accountBase); @@ -108,6 +157,7 @@ export const getAlerts = createSelector([getAlertsBase], (base) => { base.forEach(item => { arr.push({ message: item.get('message'), + message_values: item.get('message_values'), title: item.get('title'), key: item.get('key'), dismissAfter: 5000, diff --git a/app/javascript/flavours/glitch/styles/_mixins.scss b/app/javascript/flavours/glitch/styles/_mixins.scss index d542b1083..088b41e76 100644 --- a/app/javascript/flavours/glitch/styles/_mixins.scss +++ b/app/javascript/flavours/glitch/styles/_mixins.scss @@ -62,24 +62,6 @@ color: $darker-text-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; - } } @mixin search-popout() { diff --git a/app/javascript/flavours/glitch/styles/about.scss b/app/javascript/flavours/glitch/styles/about.scss index 0e910693d..a38ca99b4 100644 --- a/app/javascript/flavours/glitch/styles/about.scss +++ b/app/javascript/flavours/glitch/styles/about.scss @@ -17,109 +17,102 @@ $small-breakpoint: 960px; .rich-formatting { font-family: $font-sans-serif, sans-serif; - font-size: 16px; + font-size: 14px; font-weight: 400; - font-size: 16px; - line-height: 30px; + line-height: 1.7; + word-wrap: break-word; color: $darker-text-color; - padding-right: 10px; a { color: $highlight-text-color; text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } } p, li { - font-family: $font-sans-serif, sans-serif; - font-size: 16px; - font-weight: 400; - font-size: 16px; - line-height: 30px; - margin-bottom: 12px; color: $darker-text-color; + } - a { - color: $highlight-text-color; - text-decoration: underline; - } + p { + margin-top: 0; + margin-bottom: .85em; &:last-child { margin-bottom: 0; } } - strong, - em { + strong { font-weight: 700; - color: lighten($darker-text-color, 10%); + color: $secondary-text-color; } - h1 { - font-family: $font-display, sans-serif; - font-size: 26px; - line-height: 30px; - font-weight: 500; - margin-bottom: 20px; + em { + font-style: italic; color: $secondary-text-color; + } - small { - font-family: $font-sans-serif, sans-serif; - display: block; - font-size: 18px; - font-weight: 400; - color: lighten($darker-text-color, 10%); - } + code { + font-size: 0.85em; + background: darken($ui-base-color, 8%); + border-radius: 4px; + padding: 0.2em 0.3em; } - h2 { + h1, + h2, + h3, + h4, + h5, + h6 { font-family: $font-display, sans-serif; - font-size: 22px; - line-height: 26px; + margin-top: 1.275em; + margin-bottom: .85em; font-weight: 500; - margin-bottom: 20px; color: $secondary-text-color; } + h1 { + font-size: 2em; + } + + h2 { + font-size: 1.75em; + } + h3 { - font-family: $font-display, sans-serif; - font-size: 18px; - line-height: 24px; - font-weight: 500; - margin-bottom: 20px; - color: $secondary-text-color; + font-size: 1.5em; } h4 { - font-family: $font-display, sans-serif; - font-size: 16px; - line-height: 24px; - font-weight: 500; - margin-bottom: 20px; - color: $secondary-text-color; + font-size: 1.25em; } - h5 { - font-family: $font-display, sans-serif; - font-size: 14px; - line-height: 24px; - font-weight: 500; - margin-bottom: 20px; - color: $secondary-text-color; + h5, + h6 { + font-size: 1em; } - h6 { - font-family: $font-display, sans-serif; - font-size: 12px; - line-height: 24px; - font-weight: 500; - margin-bottom: 20px; - color: $secondary-text-color; + ul { + list-style: disc; + } + + ol { + list-style: decimal; } ul, ol { - margin-left: 20px; + margin: 0; + padding: 0; + padding-left: 2em; + margin-bottom: 0.85em; &[type='a'] { list-style-type: lower-alpha; @@ -130,31 +123,74 @@ $small-breakpoint: 960px; } } - ul { - list-style: disc; - } - - ol { - list-style: decimal; - } - - li > ol, - li > ul { - margin-top: 6px; - } - hr { width: 100%; height: 0; border: 0; - border-bottom: 1px solid rgba($ui-base-lighter-color, .6); - margin: 20px 0; + border-bottom: 1px solid lighten($ui-base-color, 4%); + margin: 1.7em 0; &.spacer { height: 1px; border: 0; } } + + table { + width: 100%; + border-collapse: collapse; + break-inside: auto; + margin-top: 24px; + margin-bottom: 32px; + + thead tr, + tbody tr { + border-bottom: 1px solid lighten($ui-base-color, 4%); + font-size: 1em; + line-height: 1.625; + font-weight: 400; + text-align: left; + color: $darker-text-color; + } + + thead tr { + border-bottom-width: 2px; + line-height: 1.5; + font-weight: 500; + color: $dark-text-color; + } + + th, + td { + padding: 8px; + align-self: start; + align-items: start; + word-break: break-all; + + &.nowrap { + width: 25%; + position: relative; + + &::before { + content: ' '; + visibility: hidden; + } + + span { + position: absolute; + left: 8px; + right: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + } + + & > :first-child { + margin-top: 0; + } } .information-board { @@ -418,7 +454,7 @@ $small-breakpoint: 960px; } &__call-to-action { - background: darken($ui-base-color, 4%); + background: $ui-base-color; border-radius: 4px; padding: 25px 40px; overflow: hidden; diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss index 0fae137f0..a827d271a 100644 --- a/app/javascript/flavours/glitch/styles/accounts.scss +++ b/app/javascript/flavours/glitch/styles/accounts.scss @@ -226,6 +226,7 @@ } .account__header__fields { + max-width: 100vw; padding: 0; margin: 15px -15px -15px; border: 0 none; diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index 74f91599a..1d25d0129 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -5,21 +5,66 @@ $content-width: 840px; .admin-wrapper { display: flex; justify-content: center; - height: 100%; + width: 100%; + min-height: 100vh; .sidebar-wrapper { - flex: 1 1 $sidebar-width; - height: 100%; - background: $ui-base-color; - display: flex; - justify-content: flex-end; + min-height: 100vh; + overflow: hidden; + pointer-events: none; + flex: 1 1 auto; + + &__inner { + display: flex; + justify-content: flex-end; + background: $ui-base-color; + height: 100%; + } } .sidebar { width: $sidebar-width; - height: 100%; padding: 0; - overflow-y: auto; + pointer-events: auto; + + &__toggle { + display: none; + background: lighten($ui-base-color, 8%); + height: 48px; + + &__logo { + flex: 1 1 auto; + + a { + display: inline-block; + padding: 15px; + } + + svg { + fill: $primary-text-color; + height: 20px; + position: relative; + bottom: -2px; + } + } + + &__icon { + display: block; + color: $darker-text-color; + text-decoration: none; + flex: 0 0 auto; + font-size: 20px; + padding: 15px; + } + + a { + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 12%); + } + } + } .logo { display: block; @@ -52,6 +97,9 @@ $content-width: 840px; transition: all 200ms linear; transition-property: color, background-color; border-radius: 4px 0 0 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; i.fa { margin-right: 5px; @@ -99,12 +147,30 @@ $content-width: 840px; } .content-wrapper { - flex: 2 1 $content-width; - overflow: auto; + box-sizing: border-box; + width: 100%; + max-width: $content-width; + flex: 1 1 auto; + } + + @media screen and (max-width: $content-width + $sidebar-width) { + .sidebar-wrapper--empty { + display: none; + } + + .sidebar-wrapper { + width: $sidebar-width; + flex: 0 0 auto; + } + } + + @media screen and (max-width: $no-columns-breakpoint) { + .sidebar-wrapper { + width: 100%; + } } .content { - max-width: $content-width; padding: 20px 15px; padding-top: 60px; padding-left: 25px; @@ -123,6 +189,12 @@ $content-width: 840px; padding-bottom: 40px; border-bottom: 1px solid lighten($ui-base-color, 8%); margin-bottom: 40px; + + @media screen and (max-width: $no-columns-breakpoint) { + border-bottom: 0; + padding-bottom: 0; + font-weight: 700; + } } h3 { @@ -147,7 +219,7 @@ $content-width: 840px; font-size: 16px; color: $secondary-text-color; line-height: 28px; - font-weight: 400; + font-weight: 500; } .fields-group h6 { @@ -176,7 +248,7 @@ $content-width: 840px; & > p { font-size: 14px; - line-height: 18px; + line-height: 21px; color: $secondary-text-color; margin-bottom: 20px; @@ -204,61 +276,98 @@ $content-width: 840px; border: 0; } } - - .muted-hint { - color: $darker-text-color; - - a { - color: $highlight-text-color; - } - } - - .positive-hint { - color: $valid-value-color; - font-weight: 500; - } - - .negative-hint { - color: $error-value-color; - font-weight: 500; - } - - .neutral-hint { - color: $dark-text-color; - font-weight: 500; - } } @media screen and (max-width: $no-columns-breakpoint) { display: block; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - .sidebar-wrapper, - .content-wrapper { - flex: 0 0 auto; - height: auto; - overflow: initial; + .sidebar-wrapper { + min-height: 0; } .sidebar { width: 100%; padding: 0; height: auto; + + &__toggle { + display: flex; + } + + & > ul { + display: none; + } + + ul a, + ul ul a { + border-radius: 0; + border-bottom: 1px solid lighten($ui-base-color, 4%); + transition: none; + + &:hover { + transition: none; + } + } + + ul ul { + border-radius: 0; + } + + ul .simple-navigation-active-leaf a { + border-bottom-color: $ui-highlight-color; + } } } } +hr.spacer { + width: 100%; + border: 0; + margin: 20px 0; + height: 1px; +} + +body, +.admin-wrapper .content { + .muted-hint { + color: $darker-text-color; + + a { + color: $highlight-text-color; + } + } + + .positive-hint { + color: $valid-value-color; + font-weight: 500; + } + + .negative-hint { + color: $error-value-color; + font-weight: 500; + } + + .neutral-hint { + color: $dark-text-color; + font-weight: 500; + } + + .warning-hint { + color: $gold-star; + font-weight: 500; + } +} + .filters { display: flex; flex-wrap: wrap; .filter-subset { flex: 0 0 auto; - margin: 0 40px 10px 0; + margin: 0 40px 20px 0; &:last-child { - margin-bottom: 20px; + margin-bottom: 30px; } ul { @@ -720,3 +829,47 @@ a.name-tag, text-overflow: ellipsis; vertical-align: middle; } + +.admin-account-bio { + display: flex; + flex-wrap: wrap; + margin: 0 -5px; + margin-top: 20px; + + > div { + box-sizing: border-box; + padding: 0 5px; + margin-bottom: 10px; + flex: 1 0 50%; + } + + .account__header__fields, + .account__header__content { + background: lighten($ui-base-color, 8%); + border-radius: 4px; + height: 100%; + } + + .account__header__fields { + margin: 0; + border: 0; + + a { + color: lighten($ui-highlight-color, 8%); + } + + dl:first-child .verified { + border-radius: 0 4px 0 0; + } + + .verified a { + color: $valid-value-color; + } + } + + .account__header__content { + box-sizing: border-box; + padding: 20px; + color: $primary-text-color; + } +} diff --git a/app/javascript/flavours/glitch/styles/basics.scss b/app/javascript/flavours/glitch/styles/basics.scss index 550b7fdfc..77631097a 100644 --- a/app/javascript/flavours/glitch/styles/basics.scss +++ b/app/javascript/flavours/glitch/styles/basics.scss @@ -7,7 +7,7 @@ body { font-family: $font-sans-serif, sans-serif; - background: darken($ui-base-color, 8%); + background: darken($ui-base-color, 7%); font-size: 13px; line-height: 18px; font-weight: 400; @@ -34,11 +34,19 @@ body { } &.app-body { - position: absolute; - width: 100%; - height: 100%; padding: 0; - background: $ui-base-color; + + &.layout-single-column { + height: auto; + min-height: 100vh; + overflow-y: scroll; + } + + &.layout-multiple-columns { + position: absolute; + width: 100%; + height: 100%; + } &.with-modals--active { overflow-y: hidden; @@ -55,7 +63,6 @@ body { &--active { overflow-y: hidden; - margin-right: 13px; } } @@ -74,9 +81,6 @@ body { &.admin { background: darken($ui-base-color, 4%); - position: fixed; - width: 100%; - height: 100%; padding: 0; } @@ -127,9 +131,22 @@ button { & > div { display: flex; width: 100%; - height: 100%; align-items: center; justify-content: center; outline: 0 !important; } } + +.layout-single-column .app-holder { + &, + & > div { + min-height: 100vh; + } +} + +.layout-multiple-columns .app-holder { + &, + & > div { + height: 100%; + } +} diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss index d2233207d..5be4da48a 100644 --- a/app/javascript/flavours/glitch/styles/components/accounts.scss +++ b/app/javascript/flavours/glitch/styles/components/accounts.scss @@ -50,6 +50,8 @@ &-composite { @include avatar-radius; overflow: hidden; + position: relative; + cursor: default; & div { @include avatar-radius; @@ -57,6 +59,18 @@ position: relative; box-sizing: border-box; } + + &__label { + display: block; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: $primary-text-color; + text-shadow: 1px 1px 2px $base-shadow-color; + font-weight: 700; + font-size: 15px; + } } } @@ -245,6 +259,28 @@ .column-select { &__control { @include search-input(); + + &::placeholder { + color: lighten($darker-text-color, 4%); + } + + &::-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; + } } &__placeholder { @@ -415,6 +451,24 @@ } } } + + &.directory__section-headline { + background: darken($ui-base-color, 2%); + border-bottom-color: transparent; + + a, + button { + &.active { + &::before { + display: none; + } + + &::after { + border-color: transparent transparent darken($ui-base-color, 7%); + } + } + } + } } .account__moved-note { diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss index b354e7acf..6ba9698c5 100644 --- a/app/javascript/flavours/glitch/styles/components/columns.scss +++ b/app/javascript/flavours/glitch/styles/components/columns.scss @@ -17,6 +17,7 @@ justify-content: center; width: 100%; height: 100%; + min-height: 100vh; &__pane { height: 100%; @@ -24,12 +25,14 @@ pointer-events: none; display: flex; justify-content: flex-end; + min-width: 285px; &--start { justify-content: flex-start; } &__inner { + position: fixed; width: 285px; pointer-events: auto; height: 100%; @@ -40,16 +43,37 @@ box-sizing: border-box; width: 100%; max-width: 600px; + flex: 0 0 auto; display: flex; flex-direction: column; - @media screen and (min-width: 360px) { + @media screen and (min-width: $no-gap-breakpoint) { padding: 0 10px; } } } } +.tabs-bar__wrapper { + background: darken($ui-base-color, 8%); + position: sticky; + top: 0; + z-index: 2; + padding-top: 0; + + @media screen and (min-width: $no-gap-breakpoint) { + padding-top: 10px; + } + + .tabs-bar { + margin-bottom: 0; + + @media screen and (min-width: $no-gap-breakpoint) { + margin-bottom: 10px; + } + } +} + .react-swipeable-view-container { &, .columns-area, @@ -83,7 +107,6 @@ flex-direction: column; width: 100%; height: 100%; - background: darken($ui-base-color, 7%); } .column { @@ -91,6 +114,8 @@ } .column-back-button { + box-sizing: border-box; + width: 100%; background: lighten($ui-base-color, 4%); color: $highlight-text-color; cursor: pointer; @@ -466,14 +491,14 @@ } .auto-columns.navbar-under { - @media screen and (max-width: 360px) { + @media screen and (max-width: $no-gap-breakpoint) { @include fix-margins-for-navbar-under; } } .auto-columns.navbar-under .react-swipeable-view-container .columns-area, .single-column.navbar-under .react-swipeable-view-container .columns-area { - @media screen and (max-width: 360px) { + @media screen and (max-width: $no-gap-breakpoint) { height: 100% !important; } } diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss index c06d79ffc..51287f62e 100644 --- a/app/javascript/flavours/glitch/styles/components/composer.scss +++ b/app/javascript/flavours/glitch/styles/components/composer.scss @@ -2,6 +2,18 @@ padding: 10px; } +.character-counter { + cursor: default; + font-family: $font-sans-serif, sans-serif; + font-size: 14px; + font-weight: 600; + color: $lighter-text-color; + + &.character-counter--over { + color: $warning-red; + } +} + .no-reduce-motion .composer--spoiler { transition: height 0.4s ease, opacity 0.4s ease; } @@ -32,6 +44,10 @@ font-family: inherit; resize: vertical; + &::placeholder { + color: $dark-text-color; + } + &:focus { outline: 0 } @include single-column('screen and (max-width: 630px)') { font-size: 16px } } @@ -127,6 +143,7 @@ overflow: visible; white-space: pre-wrap; padding-top: 5px; + overflow: hidden; p, pre, blockquote { margin-bottom: 20px; @@ -232,6 +249,7 @@ .compose-form__autosuggest-wrapper, .autosuggest-input { position: relative; + width: 100%; label { .autosuggest-textarea__textarea { @@ -251,6 +269,10 @@ resize: none; scrollbar-color: initial; + &::placeholder { + color: $dark-text-color; + } + &::-webkit-scrollbar { all: unset; } @@ -314,29 +336,46 @@ } .autosuggest-textarea__suggestions__item { - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - border-radius: 4px; padding: 10px; - font-size: 14px; - line-height: 18px; - overflow: hidden; cursor: pointer; + border-radius: 4px; &:hover, &:focus, &:active, &.selected { background: darken($ui-secondary-color, 10%) } - & > .emoji { - img { - display: block; - float: left; - margin-right: 8px; - width: 18px; - height: 18px; + > .account, + > .emoji, + > .autosuggest-hashtag { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + line-height: 18px; + font-size: 14px; + } + + .autosuggest-hashtag { + justify-content: space-between; + + &__name { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { + font-weight: 500; + } + + &__uses { + flex: 0 0 auto; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } } @@ -370,6 +409,7 @@ border-radius: 4px; height: 140px; width: 100%; + background-color: $base-shadow-color; background-position: center; background-size: cover; background-repeat: no-repeat; @@ -488,12 +528,18 @@ background: $simple-background-color; } -.composer--options { +.composer--options-wrapper { 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; height: 27px; + display: flex; + justify-content: space-between; + flex: 0 0 auto; +} + +.composer--options { + display: flex; flex: 0 0 auto; & > * { @@ -518,6 +564,11 @@ } } +.compose--counter-wrapper { + align-self: center; + margin-right: 4px; +} + .composer--options--dropdown { &.open { & > .value { @@ -588,13 +639,6 @@ justify-content: flex-end; flex: 0 0 auto; - & > .count { - display: inline-block; - margin: 0 16px 0 8px; - font-size: 16px; - line-height: 36px; - } - & > .primary { display: inline-block; margin: 0; @@ -604,7 +648,7 @@ & > .side_arm { display: inline-block; - margin: 0 2px 0 0; + margin: 0 2px; padding: 0; width: 36px; text-align: center; diff --git a/app/javascript/flavours/glitch/styles/components/directory.scss b/app/javascript/flavours/glitch/styles/components/directory.scss new file mode 100644 index 000000000..b0ad5a88a --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/directory.scss @@ -0,0 +1,180 @@ +.directory { + &__list { + width: 100%; + margin: 10px 0; + transition: opacity 100ms ease-in; + + &.loading { + opacity: 0.7; + } + + @media screen and (max-width: $no-gap-breakpoint) { + margin: 0; + } + } + + &__card { + box-sizing: border-box; + margin-bottom: 10px; + + &__img { + height: 125px; + position: relative; + background: darken($ui-base-color, 12%); + overflow: hidden; + + img { + display: block; + width: 100%; + height: 100%; + margin: 0; + object-fit: cover; + } + } + + &__bar { + display: flex; + align-items: center; + background: lighten($ui-base-color, 4%); + padding: 10px; + + &__name { + flex: 1 1 auto; + display: flex; + align-items: center; + text-decoration: none; + overflow: hidden; + } + + &__relationship { + width: 23px; + min-height: 1px; + flex: 0 0 auto; + } + + .avatar { + flex: 0 0 auto; + width: 48px; + height: 48px; + padding-top: 2px; + + img { + width: 100%; + height: 100%; + display: block; + margin: 0; + border-radius: 4px; + background: darken($ui-base-color, 8%); + object-fit: cover; + } + } + + .display-name { + margin-left: 15px; + text-align: left; + + strong { + font-size: 15px; + color: $primary-text-color; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + } + + span { + display: block; + font-size: 14px; + color: $darker-text-color; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + &__extra { + background: $ui-base-color; + display: flex; + align-items: center; + justify-content: center; + + .accounts-table__count { + width: 33.33%; + flex: 0 0 auto; + padding: 15px 0; + } + + .account__header__content { + box-sizing: border-box; + padding: 15px 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + width: 100%; + min-height: 18px + 30px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + p { + display: none; + + &:first-child { + display: inline; + } + } + + br { + display: none; + } + } + } + } +} + +.filter-form { + background: $ui-base-color; + + &__column { + padding: 10px 15px; + } + + .radio-button { + display: block; + } +} + +.radio-button { + font-size: 14px; + position: relative; + display: inline-block; + padding: 6px 0; + line-height: 18px; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + + input[type=radio], + input[type=checkbox] { + display: none; + } + + &__input { + display: inline-block; + position: relative; + border: 1px solid $ui-primary-color; + box-sizing: border-box; + width: 18px; + height: 18px; + flex: 0 0 auto; + margin-right: 10px; + top: -1px; + border-radius: 50%; + vertical-align: middle; + + &.checked { + border-color: lighten($ui-highlight-color, 8%); + background: lighten($ui-highlight-color, 8%); + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss index f054ddbc0..93a3f62ed 100644 --- a/app/javascript/flavours/glitch/styles/components/drawer.scss +++ b/app/javascript/flavours/glitch/styles/components/drawer.scss @@ -73,108 +73,31 @@ } } -.drawer--search { +.search { position: relative; margin-bottom: 10px; flex: none; - @include limited-single-column('screen and (max-width: 360px)') { margin-bottom: 0 } + @include limited-single-column('screen and (max-width: #{$no-gap-breakpoint})') { margin-bottom: 0 } @include single-column('screen and (max-width: 630px)') { font-size: 16px } - - input { - display: block; - box-sizing: border-box; - margin: 0; - border: none; - padding: 15px 30px 15px 15px; - width: 100%; - outline: 0; - color: $darker-text-color; - background: $ui-base-color; - font-size: 14px; - font-family: inherit; - line-height: 16px; - - &:focus { - outline: 0; - background: lighten($ui-base-color, 4%); - } - } - - & > .icon { - display: block; - position: absolute; - top: 10px; - right: 10px; - width: 18px; - height: 18px; - color: $secondary-text-color; - font-size: 18px; - line-height: 18px; - z-index: 2; - - .fa { - display: inline-block; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - opacity: 0; - cursor: default; - pointer-events: none; - transition: all 100ms linear; - transition-property: color, transform, opacity; - } - - .fa-search { - opacity: 0.3; - transform: rotate(0deg); - } - - .fa-times-circle { - transform: rotate(-90deg); - cursor: pointer; - - &:hover { color: $primary-text-color } - } - } - - &.active { - & > .icon { - .fa-search { - opacity: 0; - transform: rotate(90deg); - } - - .fa-times-circle { - opacity: 0.3; - pointer-events: auto; - transform: rotate(0deg); - } - } - } } -.drawer--search--popout { +.search-popout { @include search-popout(); } .drawer--account { padding: 10px; color: $darker-text-color; + display: flex; + align-items: center; - & > a { + a { color: inherit; text-decoration: none; } - & > .avatar { - float: left; - margin-right: 10px; - } - - & > .acct { + .acct { display: block; color: $secondary-text-color; font-weight: 500; @@ -184,6 +107,12 @@ } } +.navigation-bar__profile { + flex: 1 1 auto; + margin-left: 8px; + overflow: hidden; +} + .drawer--results { background: $ui-base-color; overflow-x: hidden; diff --git a/app/javascript/flavours/glitch/styles/components/error_boundary.scss b/app/javascript/flavours/glitch/styles/components/error_boundary.scss index f9bf425f8..3176690e2 100644 --- a/app/javascript/flavours/glitch/styles/components/error_boundary.scss +++ b/app/javascript/flavours/glitch/styles/components/error_boundary.scss @@ -1,4 +1,8 @@ .error-boundary { + color: $primary-text-color; + font-size: 15px; + line-height: 20px; + h1 { font-size: 26px; line-height: 36px; @@ -6,27 +10,21 @@ margin-bottom: 8px; } - p { + a { color: $primary-text-color; - font-size: 15px; - line-height: 20px; - - a { - color: $primary-text-color; - text-decoration: underline; - } + text-decoration: underline; + } - ul { - list-style: disc; - margin-left: 0; - padding-left: 1em; - } + ul { + list-style: disc; + margin-left: 0; + padding-left: 1em; + } - textarea.web_app_crash-stacktrace { - width: 100%; - resize: none; - white-space: pre; - font-family: $font-monospace, monospace; - } + textarea.web_app_crash-stacktrace { + width: 100%; + resize: none; + white-space: pre; + font-family: $font-monospace, monospace; } } diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 9f96a3154..febc95513 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -3,6 +3,27 @@ -ms-overflow-style: -ms-autohiding-scrollbar; } +.link-button { + display: block; + font-size: 15px; + line-height: 20px; + color: $ui-highlight-color; + border: 0; + background: transparent; + padding: 0; + cursor: pointer; + + &:hover, + &:active { + text-decoration: underline; + } + + &:disabled { + color: $ui-primary-color; + cursor: default; + } +} + .button { background-color: darken($ui-highlight-color, 3%); border: 10px none; @@ -118,20 +139,29 @@ display: inline-block; padding: 0; color: $action-button-color; - border: none; + border: 0; + border-radius: 4px; background: transparent; cursor: pointer; - transition: color 100ms ease-in; + transition: all 100ms ease-in; + transition-property: background-color, color; &:hover, &:active, &:focus { color: lighten($action-button-color, 7%); - transition: color 200ms ease-out; + background-color: rgba($action-button-color, 0.15); + transition: all 200ms ease-out; + transition-property: background-color, color; + } + + &:focus { + background-color: rgba($action-button-color, 0.3); } &.disabled { color: darken($action-button-color, 13%); + background-color: transparent; cursor: default; } @@ -156,10 +186,16 @@ &:active, &:focus { color: darken($lighter-text-color, 7%); + background-color: rgba($lighter-text-color, 0.15); + } + + &:focus { + background-color: rgba($lighter-text-color, 0.3); } &.disabled { color: lighten($lighter-text-color, 7%); + background-color: transparent; } &.active { @@ -186,7 +222,8 @@ .text-icon-button { color: $lighter-text-color; - border: none; + border: 0; + border-radius: 4px; background: transparent; cursor: pointer; font-weight: 600; @@ -194,17 +231,25 @@ padding: 0 3px; line-height: 27px; outline: 0; - transition: color 100ms ease-in; + transition: all 100ms ease-in; + transition-property: background-color, color; &:hover, &:active, &:focus { color: darken($lighter-text-color, 7%); - transition: color 200ms ease-out; + background-color: rgba($lighter-text-color, 0.15); + transition: all 200ms ease-out; + transition-property: background-color, color; + } + + &:focus { + background-color: rgba($lighter-text-color, 0.3); } &.disabled { color: lighten($lighter-text-color, 20%); + background-color: transparent; cursor: default; } @@ -269,6 +314,20 @@ color: $red-bookmark; } +.no-reduce-motion .icon-button.star-icon { + &.activate { + & > .fa-star { + animation: spring-rotate-in 1s linear; + } + } + + &.deactivate { + & > .fa-star { + animation: spring-rotate-out 1s linear; + } + } +} + .notification__display-name { color: inherit; font-weight: 500; @@ -802,7 +861,8 @@ } .getting-started__wrapper, -.getting_started { +.getting_started, +.flex-spacer { background: $ui-base-color; } @@ -811,6 +871,10 @@ overflow-y: auto; } +.flex-spacer { + flex: 1 1 auto; +} + .getting-started { background: $ui-base-color; flex: 1 0 auto; @@ -858,6 +922,47 @@ } } } + + &__trends { + flex: 0 1 auto; + opacity: 1; + animation: fade 150ms linear; + margin-top: 10px; + + h4 { + font-size: 12px; + text-transform: uppercase; + color: $darker-text-color; + padding: 10px; + font-weight: 500; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + @media screen and (max-height: 810px) { + .trends__item:nth-child(3) { + display: none; + } + } + + @media screen and (max-height: 720px) { + .trends__item:nth-child(2) { + display: none; + } + } + + @media screen and (max-height: 670px) { + display: none; + } + + .trends__item { + border-bottom: 0; + padding: 10px; + + &__current { + color: $darker-text-color; + } + } + } } .column-link__badge { @@ -1097,6 +1202,50 @@ animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000); } +@keyframes spring-rotate-in { + 0% { + transform: rotate(0deg); + } + + 30% { + transform: rotate(-484.8deg); + } + + 60% { + transform: rotate(-316.7deg); + } + + 90% { + transform: rotate(-375deg); + } + + 100% { + transform: rotate(-360deg); + } +} + +@keyframes spring-rotate-out { + 0% { + transform: rotate(-360deg); + } + + 30% { + transform: rotate(124.8deg); + } + + 60% { + transform: rotate(-43.27deg); + } + + 90% { + transform: rotate(15deg); + } + + 100% { + transform: rotate(0deg); + } +} + @keyframes loader-figure { 0% { width: 0; @@ -1148,6 +1297,10 @@ align-items: center; } + &--click-thru { + pointer-events: none; + } + &--hidden { display: none; } @@ -1176,6 +1329,12 @@ background: rgba($base-overlay-background, 0.8); } } + + &:disabled { + .spoiler-button__overlay__label { + background: rgba($base-overlay-background, 0.5); + } + } } } @@ -1314,6 +1473,7 @@ position: absolute; top: 0; left: 0; + z-index: 9999; } .icon-badge-wrapper { @@ -1332,52 +1492,104 @@ height: 1em; } -.layout-toggle { +.conversation { display: flex; + border-bottom: 1px solid lighten($ui-base-color, 8%); padding: 5px; + padding-bottom: 0; - button { - box-sizing: border-box; - flex: 0 0 50%; - background: transparent; - padding: 5px; - border: 0; + &:focus { + background: lighten($ui-base-color, 2%); + outline: 0; + } + + &__avatar { + flex: 0 0 auto; + padding: 10px; + padding-top: 12px; position: relative; + } - &:hover, - &:focus, - &:active { - svg path:first-child { - fill: lighten($ui-base-color, 16%); + &__unread { + display: inline-block; + background: $highlight-text-color; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; + margin: -.1ex .15em .1ex; + } + + &__content { + flex: 1 1 auto; + padding: 10px 5px; + padding-right: 15px; + overflow: hidden; + + &__info { + overflow: hidden; + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + } + + &__relative-time { + font-size: 15px; + color: $darker-text-color; + padding-left: 15px; + } + + &__names { + color: $darker-text-color; + font-size: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; + flex-basis: 90px; + flex-grow: 1; + + a { + color: $primary-text-color; + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } } } + + .status__content { + margin: 0; + } } - svg { - width: 100%; - height: auto; + &--unread { + background: lighten($ui-base-color, 2%); - path:first-child { - fill: lighten($ui-base-color, 12%); + &:focus { + background: lighten($ui-base-color, 4%); } - path:last-child { - fill: darken($ui-base-color, 14%); + .conversation__content__info { + font-weight: 700; } - } - &__active { - color: $ui-highlight-color; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: lighten($ui-base-color, 12%); - border-radius: 50%; - padding: 0.35rem; + .conversation__content__relative-time { + color: $primary-text-color; + } } } +.ui .flash-message { + margin-top: 10px; + margin-left: auto; + margin-right: auto; + margin-bottom: 0; + min-width: 75%; +} + ::-webkit-scrollbar-thumb { border-radius: 0; } @@ -1405,6 +1617,10 @@ noscript { text-decoration: none; } } + + a { + word-break: break-word; + } } } @@ -1422,6 +1638,7 @@ noscript { @import 'composer'; @import 'columns'; @import 'regeneration_indicator'; +@import 'directory'; @import 'search'; @import 'emoji'; @import 'doodle'; diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss index 8b5d0486d..39bfaae9a 100644 --- a/app/javascript/flavours/glitch/styles/components/media.scss +++ b/app/javascript/flavours/glitch/styles/components/media.scss @@ -179,10 +179,13 @@ position: absolute; } -.video-modal { +.video-modal__container { max-width: 100vw; max-height: 100vh; - position: relative; +} + +.audio-modal__container { + width: 50vw; } .media-modal { @@ -282,6 +285,7 @@ } a { + pointer-events: auto; text-decoration: none; font-weight: 500; color: $ui-secondary-color; @@ -333,11 +337,66 @@ } +.audio-player { + box-sizing: border-box; + position: relative; + background: darken($ui-base-color, 8%); + border-radius: 4px; + padding-bottom: 44px; + direction: ltr; + + &.editable { + border-radius: 0; + height: 100%; + } + + &__waveform { + padding: 15px 0; + position: relative; + overflow: hidden; + + &::before { + content: ""; + display: block; + position: absolute; + border-top: 1px solid lighten($ui-base-color, 4%); + width: 100%; + height: 0; + left: 0; + top: calc(50% + 1px); + } + } + + &__progress-placeholder { + background-color: rgba(lighten($ui-highlight-color, 8%), 0.5); + } + + &__wave-placeholder { + background-color: lighten($ui-base-color, 16%); + } + + .video-player__controls { + padding: 0 15px; + padding-top: 10px; + background: darken($ui-base-color, 8%); + border-top: 1px solid lighten($ui-base-color, 4%); + border-radius: 0 0 4px 4px; + } +} + .video-player { overflow: hidden; position: relative; background: $base-shadow-color; max-width: 100%; + border-radius: 4px; + box-sizing: border-box; + direction: ltr; + + &.editable { + border-radius: 0; + height: 100% !important; + } &:focus { outline: 0; @@ -367,6 +426,7 @@ max-height: 100% !important; width: 100% !important; height: 100% !important; + outline: 0; } } @@ -444,6 +504,17 @@ display: flex; justify-content: space-between; padding-bottom: 10px; + + .video-player__download__icon { + color: inherit; + + .fa, + &:active .fa, + &:hover .fa, + &:focus .fa { + color: inherit; + } + } } &__buttons { @@ -616,38 +687,13 @@ } } -&.detailed, -&.fullscreen { - .video-player__buttons { - button { - padding-top: 10px; - padding-bottom: 10px; + &.detailed, + &.fullscreen { + .video-player__buttons { + button { + padding-top: 10px; + padding-bottom: 10px; + } } } } -} - -.media-spoiler-video { - background-size: cover; - background-repeat: no-repeat; - background-position: center; - cursor: pointer; - margin-top: 8px; - position: relative; - - @include fullwidth-gallery; - - 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%); -} diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss index 65b2e75f0..75bddeefc 100644 --- a/app/javascript/flavours/glitch/styles/components/modal.scss +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -405,7 +405,8 @@ .confirmation-modal, .report-modal, .actions-modal, -.mute-modal { +.mute-modal, +.block-modal { background: lighten($ui-secondary-color, 8%); color: $inverted-text-color; border-radius: 8px; @@ -465,7 +466,8 @@ .boost-modal__action-bar, .favourite-modal__action-bar, .confirmation-modal__action-bar, -.mute-modal__action-bar { +.mute-modal__action-bar, +.block-modal__action-bar { display: flex; justify-content: space-between; background: $ui-secondary-color; @@ -495,11 +497,13 @@ font-size: 14px; } -.mute-modal { +.mute-modal, +.block-modal { line-height: 24px; } -.mute-modal .react-toggle { +.mute-modal .react-toggle, +.block-modal .react-toggle { vertical-align: middle; } @@ -528,7 +532,8 @@ } } -.report-modal__statuses { +.report-modal__statuses, +.focal-point-modal__content { flex: 1 1 auto; min-height: 20vh; max-height: 80vh; @@ -544,6 +549,12 @@ } } +.focal-point-modal__content { + @media screen and (max-width: 480px) { + max-height: 40vh; + } +} + .report-modal__comment { padding: 20px; border-right: 1px solid $ui-secondary-color; @@ -565,16 +576,56 @@ padding: 10px; font-family: inherit; font-size: 14px; - resize: vertical; + resize: none; border: 0; outline: 0; border-radius: 4px; border: 1px solid $ui-secondary-color; - margin-bottom: 20px; + min-height: 100px; + max-height: 50vh; + margin-bottom: 10px; &:focus { border: 1px solid darken($ui-secondary-color, 8%); } + + &__wrapper { + background: $white; + border: 1px solid $ui-secondary-color; + margin-bottom: 10px; + border-radius: 4px; + + .setting-text { + border: 0; + margin-bottom: 0; + border-radius: 0; + + &:focus { + border: 0; + } + } + + &__modifiers { + color: $inverted-text-color; + font-family: inherit; + font-size: 14px; + background: $white; + } + } + + &__toolbar { + display: flex; + justify-content: space-between; + margin-bottom: 20px; + } + } + + .setting-text-label { + display: block; + color: $inverted-text-color; + font-size: 14px; + font-weight: 500; + margin-bottom: 10px; } .setting-toggle { @@ -598,15 +649,6 @@ } } -.report-modal__target { - padding: 20px; - - .media-modal__close { - top: 19px; - right: 15px; - } -} - .actions-modal { .status { overflow-y: auto; @@ -674,27 +716,30 @@ } .confirmation-modal__action-bar, -.mute-modal__action-bar { - .confirmation-modal__secondary-button, - .confirmation-modal__cancel-button, - .mute-modal__cancel-button { - background-color: transparent; - color: $lighter-text-color; - font-size: 14px; - font-weight: 500; - - &:hover, - &:focus, - &:active { - color: darken($lighter-text-color, 4%); - } - } - +.mute-modal__action-bar, +.block-modal__action-bar { .confirmation-modal__secondary-button { flex-shrink: 1; } } +.confirmation-modal__secondary-button, +.confirmation-modal__cancel-button, +.mute-modal__cancel-button, +.block-modal__cancel-button { + background-color: transparent; + color: $lighter-text-color; + font-size: 14px; + font-weight: 500; + + &:hover, + &:focus, + &:active { + color: darken($lighter-text-color, 4%); + background-color: transparent; + } +} + .confirmation-modal__do_not_ask_again { padding-left: 20px; padding-right: 20px; @@ -709,10 +754,10 @@ .confirmation-modal__container, .mute-modal__container, +.block-modal__container, .report-modal__target { padding: 30px; font-size: 16px; - text-align: center; strong { font-weight: 500; @@ -725,7 +770,42 @@ } } +.confirmation-modal__container, +.report-modal__target { + text-align: center; +} + +.block-modal, +.mute-modal { + &__explanation { + margin-top: 20px; + } + + .setting-toggle { + margin-top: 20px; + margin-bottom: 24px; + display: flex; + align-items: center; + + &__label { + color: $inverted-text-color; + margin: 0; + margin-left: 8px; + } + } +} + +.report-modal__target { + padding: 15px; + + .media-modal__close { + top: 14px; + right: 15px; + } +} + .embed-modal { + width: auto; max-width: 80vw; max-height: 80vh; @@ -756,6 +836,7 @@ font-size: 14px; margin: 0; margin-bottom: 15px; + border-radius: 4px; &::-moz-focus-inner { border: 0; @@ -781,25 +862,31 @@ max-width: 100%; overflow: hidden; border: 0; + border-radius: 4px; } } } .focal-point { position: relative; - cursor: pointer; + cursor: move; overflow: hidden; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: $base-shadow-color; - &.dragging { - cursor: move; - } - - img { - max-width: 80vw; + img, + video, + canvas { + display: block; max-height: 80vh; - width: auto; + width: 100%; height: auto; - margin: auto; + margin: 0; + object-fit: contain; + background: $base-shadow-color; } &__reticle { @@ -819,4 +906,80 @@ top: 0; left: 0; } + + &__preview { + position: absolute; + bottom: 10px; + right: 10px; + z-index: 2; + cursor: move; + transition: opacity 0.1s ease; + + &:hover { + opacity: 0.5; + } + + strong { + color: $primary-text-color; + font-size: 14px; + font-weight: 500; + display: block; + margin-bottom: 5px; + } + + div { + border-radius: 4px; + box-shadow: 0 0 14px rgba($base-shadow-color, 0.2); + } + } + + @media screen and (max-width: 480px) { + img, + video { + max-height: 100%; + } + + &__preview { + display: none; + } + } +} + +.filtered-status-info { + text-align: start; + + .spoiler__text { + margin-top: 20px; + } + + .account { + border-bottom: 0; + } + + .account__display-name strong { + color: $inverted-text-color; + } + + .status__content__spoiler { + display: none; + + &--visible { + display: flex; + } + } + + ul { + padding: 10px; + margin-left: 12px; + list-style: disc inside; + } + + .filtered-status-edit-link { + color: $action-button-color; + text-decoration: none; + + &:hover { + text-decoration: underline + } + } } diff --git a/app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss b/app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss index 178df6652..c65e6a9af 100644 --- a/app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss +++ b/app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss @@ -7,37 +7,27 @@ cursor: default; display: flex; flex: 1 1 auto; + flex-direction: column; align-items: center; justify-content: center; padding: 20px; - & > div { - width: 100%; - background: transparent; - padding-top: 0; - } - &__figure { - background: url('~flavours/glitch/images/elephant_ui_working.svg') no-repeat center 0; - width: 100%; - height: 160px; - background-size: contain; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + &, + img { + display: block; + width: auto; + height: 160px; + margin: 0; + } } - &.missing-indicator { + &--without-header { padding-top: 20px + 48px; - - .regeneration-indicator__figure { - background-image: url('~flavours/glitch/images/elephant_ui_disappointed.svg'); - } } &__label { - margin-top: 200px; + margin-top: 30px; strong { display: block; diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss index 3ef141133..30d69d05c 100644 --- a/app/javascript/flavours/glitch/styles/components/search.scss +++ b/app/javascript/flavours/glitch/styles/components/search.scss @@ -3,13 +3,47 @@ } .search__input { + @include search-input(); + display: block; - padding: 10px; + padding: 15px; padding-right: 30px; - @include search-input(); + line-height: 18px; + font-size: 16px; + + &::placeholder { + color: lighten($darker-text-color, 4%); + } + + &::-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 { + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus { + outline: 0 !important; + } + .fa { position: absolute; top: 16px; @@ -18,7 +52,7 @@ display: inline-block; opacity: 0; transition: all 100ms linear; - transition-property: transform, opacity; + transition-property: color, transform, opacity; font-size: 18px; width: 18px; height: 18px; @@ -33,17 +67,18 @@ } .fa-search { - transform: rotate(90deg); + transform: rotate(0deg); &.active { - pointer-events: none; - transform: rotate(0deg); + pointer-events: auto; + opacity: 0.3; } } .fa-times-circle { top: 17px; transform: rotate(0deg); + color: $action-button-color; cursor: pointer; &.active { @@ -51,7 +86,7 @@ } &:hover { - color: $primary-text-color; + color: lighten($action-button-color, 7%); } } } @@ -65,6 +100,12 @@ font-weight: 500; } +.search-results__info { + padding: 20px; + color: $darker-text-color; + text-align: center; +} + .trends { &__header { color: $dark-text-color; @@ -124,11 +165,12 @@ &__current { flex: 0 0 auto; - width: 100px; font-size: 24px; line-height: 36px; font-weight: 500; - text-align: center; + text-align: right; + padding-right: 15px; + margin-left: 5px; color: $secondary-text-color; } @@ -136,7 +178,12 @@ flex: 0 0 auto; width: 50px; - path { + path:first-child { + fill: rgba($highlight-text-color, 0.25) !important; + fill-opacity: 1 !important; + } + + path:last-child { stroke: lighten($highlight-text-color, 6%) !important; } } diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss index ca962abd2..edf705b5f 100644 --- a/app/javascript/flavours/glitch/styles/components/single_column.scss +++ b/app/javascript/flavours/glitch/styles/components/single_column.scss @@ -6,7 +6,7 @@ height: calc(100% - 10px); overflow-y: hidden; - .drawer--search input { + .search__input { line-height: 18px; font-size: 16px; padding: 15px; @@ -54,13 +54,24 @@ margin-bottom: 10px; height: calc(100% - 20px); overflow-y: auto; + display: flex; + flex-direction: column; + + & > a { + flex: 0 0 auto; + } hr { + flex: 0 0 auto; border: 0; background: transparent; border-top: 1px solid lighten($ui-base-color, 4%); margin: 10px 0; } + + .flex-spacer { + background: transparent; + } } @media screen and (min-width: 600px) { @@ -83,6 +94,24 @@ padding: 0; } + .directory__list { + display: grid; + grid-gap: 10px; + grid-template-columns: minmax(0, 50%) minmax(0, 50%); + + @media screen and (max-width: $no-gap-breakpoint) { + display: block; + } + } + + .directory__card { + margin-bottom: 0; + } + + .filter-form { + display: flex; + } + .autosuggest-textarea__textarea { font-size: 16px; } @@ -98,8 +127,17 @@ top: 15px; } - @media screen and (min-width: 360px) { + .scrollable { + overflow: visible; + + @supports(display: grid) { + contain: content; + } + } + + @media screen and (min-width: $no-gap-breakpoint) { padding: 10px 0; + padding-top: 0; } @media screen and (min-width: 630px) { @@ -107,7 +145,8 @@ padding: 15px; .media-gallery, - .video-player { + .video-player, + .audio-player { margin-top: 15px; } } @@ -131,7 +170,8 @@ .media-gallery, &__action-bar, - .video-player { + .video-player, + .audio-player { margin-top: 10px; } } @@ -184,19 +224,16 @@ } } -@media screen and (min-width: 360px) { +@media screen and (min-width: $no-gap-breakpoint) { .tabs-bar { - margin: 10px auto; - margin-bottom: 0; width: 100%; } .react-swipeable-view-container .columns-area--mobile { - height: calc(100% - 20px) !important; + height: calc(100% - 10px) !important; } .getting-started__wrapper, - .getting-started__trends, .search { margin-bottom: 10px; } diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index ee4440e89..00f947cdc 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -1,3 +1,47 @@ +@keyframes spring-flip-in { + 0% { + transform: rotate(0deg); + } + + 30% { + transform: rotate(-242.4deg); + } + + 60% { + transform: rotate(-158.35deg); + } + + 90% { + transform: rotate(-187.5deg); + } + + 100% { + transform: rotate(-180deg); + } +} + +@keyframes spring-flip-out { + 0% { + transform: rotate(-180deg); + } + + 30% { + transform: rotate(62.4deg); + } + + 60% { + transform: rotate(-21.635deg); + } + + 90% { + transform: rotate(7.5deg); + } + + 100% { + transform: rotate(0deg); + } +} + .status__content--with-action { cursor: pointer; } @@ -33,6 +77,13 @@ .status__content__text, .e-content { + overflow: hidden; + + & > ul, + & > ol { + margin-bottom: 20px; + } + h1, h2, h3, h4, h5 { margin-top: 20px; margin-bottom: 20px; @@ -75,6 +126,11 @@ text-align: sub; } + sup { + font-size: smaller; + vertical-align: super; + } + ul, ol { margin-left: 1em; @@ -127,6 +183,15 @@ } } + a.unhandled-link { + color: lighten($ui-highlight-color, 8%); + + .link-origin-tag { + color: $gold-star; + font-size: 0.8em; + } + } + .status__content__spoiler-link { background: lighten($ui-base-color, 30%); @@ -167,7 +232,9 @@ } .notif-cleaning { - .status, .notification-follow { + .status, + .notification-follow, + .notification-follow-request { padding-right: ($dismiss-overlay-width + 0.5rem); } } @@ -187,13 +254,12 @@ } .status__prepend-icon-wrapper { - float: left; - margin: 0 10px 0 -58px; - width: 48px; - text-align: right; + left: -26px; + position: absolute; } -.notification-follow { +.notification-follow, +.notification-follow-request { position: relative; // same like Status @@ -245,7 +311,8 @@ opacity: 1; animation: fade 150ms linear; - .video-player { + .video-player, + .audio-player { margin-top: 8px; } @@ -364,9 +431,7 @@ .status__relative-time { display: inline-block; - margin-left: auto; - padding-left: 18px; - width: 120px; + flex-grow: 1; color: $dark-text-color; font-size: 14px; text-align: right; @@ -376,7 +441,6 @@ } .status__display-name { - margin: 0 auto 0 0; color: $dark-text-color; overflow: hidden; } @@ -388,6 +452,7 @@ .status__info { display: flex; + justify-content: space-between; font-size: 15px; > span { @@ -401,25 +466,41 @@ } .status__info__icons { - margin-left: auto; display: flex; align-items: center; height: 1em; color: $action-button-color; - .status__media-icon { - padding-left: 6px; - padding-right: 1px; + .status__media-icon, + .status__visibility-icon, + .status__reply-icon { + padding-left: 2px; + padding-right: 2px; + } + + .status__collapse-button.active > .fa-angle-double-up { + transform: rotate(-180deg); + } +} + +.no-reduce-motion .status__collapse-button { + &.activate { + & > .fa-angle-double-up { + animation: spring-flip-in 1s linear; + } } - .status__visibility-icon { - padding-left: 4px; + &.deactivate { + & > .fa-angle-double-up { + animation: spring-flip-out 1s linear; + } } } .status__info__account { display: flex; align-items: center; + justify-content: flex-start; } .status-check-box { @@ -439,7 +520,8 @@ white-space: normal; } - .video-player { + .video-player, + .audio-player { margin-top: 8px; max-width: 250px; } @@ -459,9 +541,12 @@ } .status__prepend { - margin: -10px -10px 10px; + margin-top: -10px; + margin-bottom: 10px; + margin-left: 58px; color: $dark-text-color; - padding: 8px 10px 0 68px; + padding: 8px 0; + padding-bottom: 2px; font-size: 14px; position: relative; @@ -544,7 +629,8 @@ } } - .video-player { + .video-player, + .audio-player { margin-top: 8px; } } @@ -620,6 +706,10 @@ a.status__display-name, color: inherit; } +.detailed-status .button.logo-button { + margin-bottom: 15px; +} + .detailed-status__display-name { color: $secondary-text-color; display: block; @@ -653,6 +743,7 @@ a.status__display-name, } .muted { + .status__content, .status__content p, .status__content a, .status__content__text { @@ -866,67 +957,6 @@ a.status-card.compact:hover { background-position: center center; } -.status__video-player { - display: flex; - align-items: center; - background: $base-shadow-color; - box-sizing: border-box; - cursor: default; /* May not be needed */ - margin-top: 8px; - overflow: hidden; - position: relative; - - @include fullwidth-gallery; -} - -.status__video-player-video { - height: 100%; - object-fit: contain; - position: relative; - top: 50%; - transform: translateY(-50%); - width: 100%; - z-index: 1; - - &:not(.letterbox) { - height: 100%; - object-fit: cover; - } -} - -.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; -} - .attachment-list { display: flex; font-size: 14px; @@ -990,3 +1020,18 @@ a.status-card.compact:hover { } } } + +.status__wrapper--filtered__button { + display: inline; + color: lighten($ui-highlight-color, 8%); + border: 0; + background: transparent; + padding: 0; + font-size: inherit; + line-height: inherit; + + &:hover, + &:active { + text-decoration: underline; + } +} diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss index dc60dd14b..d1c6c33d7 100644 --- a/app/javascript/flavours/glitch/styles/containers.scss +++ b/app/javascript/flavours/glitch/styles/containers.scss @@ -143,10 +143,71 @@ grid-row: 3; } + @media screen and (max-width: $no-gap-breakpoint) { + grid-gap: 0; + grid-template-columns: minmax(0, 100%); + + .column-0 { + grid-column: 1; + } + + .column-1 { + grid-column: 1; + grid-row: 3; + } + + .column-2 { + grid-column: 1; + grid-row: 2; + } + + .column-3 { + grid-column: 1; + grid-row: 4; + } + } +} + +.grid-4 { + display: grid; + grid-gap: 10px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-auto-columns: 25%; + grid-auto-rows: max-content; + + .column-0 { + grid-column: 1 / 5; + grid-row: 1; + } + + .column-1 { + grid-column: 1 / 4; + grid-row: 2; + } + + .column-2 { + grid-column: 4; + grid-row: 2; + } + + .column-3 { + grid-column: 2 / 5; + grid-row: 3; + } + + .column-4 { + grid-column: 1; + grid-row: 3; + } + .landing-page__call-to-action { min-height: 100%; } + .flash-message { + margin-bottom: 10px; + } + @media screen and (max-width: 738px) { grid-template-columns: minmax(0, 50%) minmax(0, 50%); @@ -188,6 +249,11 @@ .column-3 { grid-column: 1; + grid-row: 5; + } + + .column-4 { + grid-column: 1; grid-row: 4; } } @@ -348,6 +414,20 @@ } } + .directory__card { + border-radius: 4px; + + @media screen and (max-width: $no-gap-breakpoint) { + border-radius: 0; + } + } + + .page-header { + @media screen and (max-width: $no-gap-breakpoint) { + border-bottom: 0; + } + } + .public-account-header { overflow: hidden; margin-bottom: 10px; @@ -572,7 +652,7 @@ } .counter { - width: 33.3%; + min-width: 33.3%; box-sizing: border-box; flex: 0 0 auto; color: $darker-text-color; @@ -755,14 +835,22 @@ } } - .static-icon-button { - color: $action-button-color; - font-size: 18px; + .directory__list { + display: grid; + grid-gap: 10px; + grid-template-columns: minmax(0, 50%) minmax(0, 50%); - & > span { - font-size: 14px; - font-weight: 500; + @media screen and (max-width: $no-gap-breakpoint) { + display: block; } + + .icon-button { + font-size: 18px; + } + } + + .directory__card { + margin-bottom: 0; } .card-grid { diff --git a/app/javascript/flavours/glitch/styles/dashboard.scss b/app/javascript/flavours/glitch/styles/dashboard.scss index e4564f062..c0944d417 100644 --- a/app/javascript/flavours/glitch/styles/dashboard.scss +++ b/app/javascript/flavours/glitch/styles/dashboard.scss @@ -15,6 +15,8 @@ padding: 20px; background: lighten($ui-base-color, 4%); border-radius: 4px; + box-sizing: border-box; + height: 100%; } & > a { diff --git a/app/javascript/flavours/glitch/styles/footer.scss b/app/javascript/flavours/glitch/styles/footer.scss index f74c004e9..00d290883 100644 --- a/app/javascript/flavours/glitch/styles/footer.scss +++ b/app/javascript/flavours/glitch/styles/footer.scss @@ -128,7 +128,7 @@ &:hover, &:focus, &:active { - svg path { + svg { fill: lighten($ui-base-color, 38%); } } diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss index dae29a003..1920c33ea 100644 --- a/app/javascript/flavours/glitch/styles/forms.scss +++ b/app/javascript/flavours/glitch/styles/forms.scss @@ -245,6 +245,10 @@ code { &-6 { max-width: 50%; } + + .actions { + margin-top: 27px; + } } .fields-group:last-child, @@ -300,6 +304,13 @@ code { } } + .input.static .label_input__wrapper { + font-size: 16px; + padding: 10px; + border: 1px solid $dark-text-color; + border-radius: 4px; + } + input[type=text], input[type=number], input[type=email], @@ -318,6 +329,10 @@ code { border-radius: 4px; padding: 10px; + &::placeholder { + color: lighten($darker-text-color, 4%); + } + &:invalid { box-shadow: none; } @@ -489,6 +504,10 @@ code { &__overlay-area { position: relative; + &__blurred form { + filter: blur(2px); + } + &__overlay { position: absolute; top: 0; @@ -499,8 +518,10 @@ code { justify-content: center; align-items: center; background: rgba($ui-base-color, 0.65); - backdrop-filter: blur(2px); border-radius: 4px; + margin-left: -4px; + margin-top: -4px; + padding: 4px; &__content { text-align: center; diff --git a/app/javascript/flavours/glitch/styles/index.scss b/app/javascript/flavours/glitch/styles/index.scss index 323b2e7fe..af73feb89 100644 --- a/app/javascript/flavours/glitch/styles/index.scss +++ b/app/javascript/flavours/glitch/styles/index.scss @@ -14,7 +14,7 @@ @import 'widgets'; @import 'forms'; @import 'accounts'; -@import 'stream_entries'; +@import 'statuses'; @import 'components/index'; @import 'polls'; @import 'about'; diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss index 7da8edbde..3b4ffdf3c 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss @@ -135,13 +135,12 @@ } } - .composer--options { + .composer--options-wrapper { background: lighten($ui-base-color, 10%); - box-shadow: unset; + } - & > hr { - display: none; - } + .composer--options > hr { + display: none; } .composer--options--dropdown--content--item { @@ -227,16 +226,29 @@ .boost-modal, .confirmation-modal, .mute-modal, +.block-modal, .report-modal, .embed-modal, .error-modal, -.onboarding-modal { - background: $ui-base-color; +.onboarding-modal, +.report-modal__comment .setting-text__wrapper, +.report-modal__comment .setting-text { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); +} + +.report-modal__comment { + border-right-color: lighten($ui-base-color, 8%); +} + +.report-modal__container { + border-top-color: lighten($ui-base-color, 8%); } .boost-modal__action-bar, .confirmation-modal__action-bar, .mute-modal__action-bar, +.block-modal__action-bar, .onboarding-modal__paginator, .error-modal__footer { background: darken($ui-base-color, 6%); @@ -373,3 +385,10 @@ .directory__tag > div { box-shadow: none; } + +.audio-player .video-player__controls button, +.audio-player .video-player__time-sep, +.audio-player .video-player__time-current, +.audio-player .video-player__time-total { + color: $primary-text-color; +} diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss index 1b060b58d..312f5e314 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss @@ -18,6 +18,8 @@ $darker-text-color: $classic-base-color !default; $dark-text-color: #444b5d; $action-button-color: #606984; +$success-green: lighten(#3c754d, 8%); + $base-overlay-background: $white !default; $inverted-text-color: $black !default; diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss index 5261f17f4..49d0e7f71 100644 --- a/app/javascript/flavours/glitch/styles/polls.scss +++ b/app/javascript/flavours/glitch/styles/polls.scss @@ -11,7 +11,6 @@ li { margin-bottom: 10px; position: relative; - height: 18px + 12px; } &__chart { @@ -30,13 +29,11 @@ &__text { position: relative; - display: inline-block; + display: flex; padding: 6px 0; line-height: 18px; cursor: default; - white-space: nowrap; overflow: hidden; - text-overflow: ellipsis; input[type=radio], input[type=checkbox] { @@ -89,6 +86,9 @@ top: -1px; border-radius: 50%; vertical-align: middle; + margin-top: auto; + margin-bottom: auto; + flex: 0 0 18px; &.checkbox { border-radius: 4px; @@ -98,14 +98,40 @@ border-color: $valid-value-color; background: $valid-value-color; } + + &:active, + &:focus, + &:hover { + border-width: 4px; + background: none; + } + + &::-moz-focus-inner { + outline: 0 !important; + border: 0; + } + + &:focus, + &:active { + outline: 0 !important; + } } &__number { display: inline-block; - width: 36px; + width: 52px; font-weight: 700; padding: 0 10px; + padding-left: 8px; text-align: right; + margin-top: auto; + margin-bottom: auto; + flex: 0 0 52px; + } + + &__vote__mark { + float: left; + line-height: 18px; } &__footer { @@ -159,6 +185,10 @@ select { width: 100%; flex: 1 1 50%; + + &:focus { + border-color: $highlight-text-color; + } } } diff --git a/app/javascript/flavours/glitch/styles/rtl.scss b/app/javascript/flavours/glitch/styles/rtl.scss index 11fae3121..2375bac90 100644 --- a/app/javascript/flavours/glitch/styles/rtl.scss +++ b/app/javascript/flavours/glitch/styles/rtl.scss @@ -7,6 +7,34 @@ body.rtl { padding-right: 15px; } + .radio-button__input { + margin-right: 0; + margin-left: 10px; + } + + .directory__card__bar .display-name { + margin-left: 0; + margin-right: 15px; + } + + .display-name { + text-align: right; + } + + .notification__message { + margin-left: 0; + margin-right: 68px; + } + + .drawer__inner__mastodon > img { + transform: scaleX(-1); + } + + .notification__favourite-icon-wrapper { + left: auto; + right: -26px; + } + .landing-page__logo { margin-right: 0; margin-left: 20px; @@ -28,6 +56,15 @@ body.rtl { margin-left: 4px; } + .composer--publisher { + text-align: left; + } + + .boost-modal__status-time, + .favourite-modal__status-time { + float: left; + } + .navigation-bar__profile { margin-left: 0; margin-right: 8px; @@ -50,8 +87,8 @@ body.rtl { .column-header__buttons { left: 0; right: auto; - margin-left: -15px; - margin-right: 0; + margin-left: 0; + margin-right: -15px; } .column-inline-form .icon-button { @@ -87,11 +124,14 @@ body.rtl { } .status__avatar { + margin-left: 10px; + margin-right: 0; + + // Those are used for public pages left: auto; right: 10px; } - .status, .activity-stream .status.light { padding-left: 10px; padding-right: 68px; @@ -110,7 +150,7 @@ body.rtl { .status__prepend { margin-left: 0; - margin-right: 68px; + margin-right: 58px; } .status__prepend-icon-wrapper { @@ -136,21 +176,10 @@ body.rtl { .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; + text-align: left; } .status__action-bar { - &__counter { margin-right: 0; margin-left: 11px; @@ -182,6 +211,10 @@ body.rtl { margin-right: 0; } + .detailed-status__display-name .display-name { + text-align: right; + } + .detailed-status__display-avatar { margin-right: 0; margin-left: 10px; @@ -195,7 +228,6 @@ body.rtl { } .fa-ul { - margin-left: 0; margin-left: 2.14285714em; } @@ -340,6 +372,12 @@ body.rtl { } } + .columns-area--mobile .column, + .columns-area--mobile .drawer { + padding-left: 0; + padding-right: 0; + } + .public-layout { .header { .nav-button { diff --git a/app/javascript/flavours/glitch/styles/stream_entries.scss b/app/javascript/flavours/glitch/styles/statuses.scss index de9c2612c..611d5185b 100644 --- a/app/javascript/flavours/glitch/styles/stream_entries.scss +++ b/app/javascript/flavours/glitch/styles/statuses.scss @@ -205,9 +205,20 @@ } .rtl { - .embed, .public-layout { - .status .status__relative-time { - float: left; + .embed, + .public-layout { + .status { + padding-left: 10px; + padding-right: 68px; + + .status__info .status__display-name { + padding-left: 25px; + padding-right: 0; + } + + .status__relative-time { + float: left; + } } } } diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss index 154844665..ec2ee7c1c 100644 --- a/app/javascript/flavours/glitch/styles/tables.scss +++ b/app/javascript/flavours/glitch/styles/tables.scss @@ -149,10 +149,6 @@ a.table-action-link { margin-top: 0; } } - - @media screen and (max-width: $no-gap-breakpoint) { - display: none; - } } &__actions, @@ -174,9 +170,17 @@ a.table-action-link { text-align: right; padding-right: 16px - 5px; } + } - @media screen and (max-width: $no-gap-breakpoint) { - display: none; + &__form { + padding: 16px; + border: 1px solid darken($ui-base-color, 8%); + border-top: 0; + background: $ui-base-color; + + .fields-row { + padding-top: 0; + margin-bottom: 0; } } @@ -186,7 +190,7 @@ a.table-action-link { background: darken($ui-base-color, 4%); @media screen and (max-width: $no-gap-breakpoint) { - &:first-child { + .optional &:first-child { border-top: 1px solid darken($ui-base-color, 8%); } } @@ -210,6 +214,52 @@ a.table-action-link { &--unpadded { padding: 0; } + + &--with-image { + display: flex; + align-items: center; + } + + &__image { + flex: 0 0 auto; + display: flex; + justify-content: center; + align-items: center; + margin-right: 10px; + + .emojione { + width: 32px; + height: 32px; + } + } + + &__text { + flex: 1 1 auto; + } + + &__extra { + flex: 0 0 auto; + text-align: right; + color: $darker-text-color; + font-weight: 500; + } + } + + .directory__tag { + margin: 0; + width: 100%; + + a { + background: transparent; + border-radius: 0; + } + } + } + + &.optional .batch-table__toolbar, + &.optional .batch-table__row__select { + @media screen and (max-width: $no-gap-breakpoint) { + display: none; } } diff --git a/app/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss index acaf5b024..a6f7fc0be 100644 --- a/app/javascript/flavours/glitch/styles/widgets.scss +++ b/app/javascript/flavours/glitch/styles/widgets.scss @@ -100,6 +100,16 @@ background-size: 44px 44px; } } + + .trends__item { + padding: 10px; + } +} + +.trends-widget { + h4 { + color: $darker-text-color; + } } .box-widget { @@ -109,41 +119,52 @@ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); } -.contact-widget, -.landing-page__information.contact-widget { - box-sizing: border-box; - padding: 20px; - min-height: 100%; +.placeholder-widget { + padding: 16px; border-radius: 4px; - background: $ui-base-color; - box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + border: 2px dashed $dark-text-color; + text-align: center; + color: $darker-text-color; + margin-bottom: 10px; } .contact-widget { + min-height: 100%; font-size: 15px; color: $darker-text-color; line-height: 20px; word-wrap: break-word; font-weight: 400; + padding: 0; - strong { - font-weight: 500; + h4 { + padding: 10px; + text-transform: uppercase; + font-weight: 700; + font-size: 13px; + color: $darker-text-color; } - p { - margin-bottom: 10px; - - &:last-child { - margin-bottom: 0; - } + .account { + border-bottom: 0; + padding: 10px 0; + padding-top: 5px; } - &__mail { - margin-top: 10px; + & > a { + display: inline-block; + padding: 10px; + padding-top: 0; + color: $darker-text-color; + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; - a { - color: $primary-text-color; - text-decoration: none; + &:hover, + &:focus, + &:active { + text-decoration: underline; } } } @@ -521,6 +542,12 @@ $fluid-breakpoint: $maximum-width + 20px; a { font-size: 14px; line-height: 20px; + } +} + +.notice-widget, +.placeholder-widget { + a { text-decoration: none; font-weight: 500; color: $ui-highlight-color; @@ -532,3 +559,38 @@ $fluid-breakpoint: $maximum-width + 20px; } } } + +.table-of-contents { + background: darken($ui-base-color, 4%); + min-height: 100%; + font-size: 14px; + border-radius: 4px; + + li a { + display: block; + font-weight: 500; + padding: 15px; + overflow: hidden; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-decoration: none; + color: $primary-text-color; + border-bottom: 1px solid lighten($ui-base-color, 4%); + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + + li:last-child a { + border-bottom: 0; + } + + li ul { + padding-left: 20px; + border-bottom: 1px solid lighten($ui-base-color, 4%); + } +} diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml index 06e26ade2..0fd627f19 100644 --- a/app/javascript/flavours/glitch/theme.yml +++ b/app/javascript/flavours/glitch/theme.yml @@ -18,7 +18,7 @@ pack: mailer: modal: public: packs/public.js - settings: + settings: packs/settings.js share: packs/share.js # (OPTIONAL) The directory which contains localization files for diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js index f2aeda834..26255bbb7 100644 --- a/app/javascript/flavours/glitch/util/async-components.js +++ b/app/javascript/flavours/glitch/util/async-components.js @@ -122,6 +122,10 @@ export function MuteModal () { return import(/* webpackChunkName: "flavours/glitch/async/mute_modal" */'flavours/glitch/features/ui/components/mute_modal'); } +export function BlockModal () { + return import(/* webpackChunkName: "flavours/glitch/async/block_modal" */'flavours/glitch/features/ui/components/block_modal'); +} + export function ReportModal () { return import(/* webpackChunkName: "flavours/glitch/async/report_modal" */'flavours/glitch/features/ui/components/report_modal'); } @@ -138,6 +142,10 @@ export function Video () { return import(/* webpackChunkName: "flavours/glitch/async/video" */'flavours/glitch/features/video'); } +export function Audio () { + return import(/* webpackChunkName: "features/glitch/async/audio" */'flavours/glitch/features/audio'); +} + export function EmbedModal () { return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'flavours/glitch/features/ui/components/embed_modal'); } @@ -153,3 +161,11 @@ export function ListAdder () { export function Search () { return import(/*webpackChunkName: "features/glitch/async/search" */'flavours/glitch/features/search'); } + +export function Tesseract () { + return import(/*webpackChunkName: "tesseract" */'tesseract.js'); +} + +export function Directory () { + return import(/* webpackChunkName: "features/glitch/async/directory" */'flavours/glitch/features/directory'); +} diff --git a/app/javascript/flavours/glitch/util/backend_links.js b/app/javascript/flavours/glitch/util/backend_links.js index 4fc03f919..0fb378cc1 100644 --- a/app/javascript/flavours/glitch/util/backend_links.js +++ b/app/javascript/flavours/glitch/util/backend_links.js @@ -4,3 +4,6 @@ export const signOutLink = '/auth/sign_out'; export const termsLink = '/terms'; export const accountAdminLink = (id) => `/admin/accounts/${id}`; export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`; +export const filterEditLink = (id) => `/filters/${id}/edit`; +export const relationshipsLink = '/relationships'; +export const securityLink = '/auth/edit'; diff --git a/app/javascript/flavours/glitch/util/compare_id.js b/app/javascript/flavours/glitch/util/compare_id.js index aaff66481..66cf51c4b 100644 --- a/app/javascript/flavours/glitch/util/compare_id.js +++ b/app/javascript/flavours/glitch/util/compare_id.js @@ -1,10 +1,11 @@ -export default function compareId(id1, id2) { +export default function compareId (id1, id2) { if (id1 === id2) { return 0; } + if (id1.length === id2.length) { return id1 > id2 ? 1 : -1; } else { return id1.length > id2.length ? 1 : -1; } -} +}; diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_mart_search_light.js b/app/javascript/flavours/glitch/util/emoji/emoji_mart_search_light.js index 164fdcc0b..e4519a13e 100644 --- a/app/javascript/flavours/glitch/util/emoji/emoji_mart_search_light.js +++ b/app/javascript/flavours/glitch/util/emoji/emoji_mart_search_light.js @@ -74,7 +74,7 @@ function search(value, { emojisToShowFilter, maxResults, include, exclude, custo return [emojisList['-1']]; } - let values = value.toLowerCase().split(/[\s|,|\-|_]+/), + let values = value.toLowerCase().split(/[\s|,\-_]+/), allResults = []; if (values.length > 2) { diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js index 82a1ef89c..e1a244127 100644 --- a/app/javascript/flavours/glitch/util/emoji/index.js +++ b/app/javascript/flavours/glitch/util/emoji/index.js @@ -1,4 +1,4 @@ -import { autoPlayGif } from 'flavours/glitch/util/initial_state'; +import { autoPlayGif, useSystemEmojiFont } from 'flavours/glitch/util/initial_state'; import unicodeMapping from './emoji_unicode_mapping_light'; import Trie from 'substring-trie'; @@ -12,7 +12,7 @@ const emojify = (str, customEmojis = {}) => { let rtn = '', tagChars = tagCharsWithEmojis, invisible = 0; for (;;) { let match, i = 0, tag; - while (i < str.length && (tag = tagChars.indexOf(str[i])) === -1 && (invisible || !(match = trie.search(str.slice(i))))) { + while (i < str.length && (tag = tagChars.indexOf(str[i])) === -1 && (invisible || useSystemEmojiFont || !(match = trie.search(str.slice(i))))) { i += str.codePointAt(i) < 65536 ? 1 : 2; } let rend, replacement = ''; @@ -29,7 +29,7 @@ const emojify = (str, customEmojis = {}) => { // if you want additional emoji handler, add statements below which set replacement and return true. if (shortname in customEmojis) { const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url; - replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`; + replacement = `<img draggable="false" class="emojione custom-emoji" alt="${shortname}" title="${shortname}" src="${filename}" data-original="${customEmojis[shortname].url}" data-static="${customEmojis[shortname].static_url}" />`; return true; } return false; @@ -57,7 +57,7 @@ const emojify = (str, customEmojis = {}) => { } } i = rend; - } else { // matched to unicode emoji + } else if (!useSystemEmojiFont) { // matched to unicode emoji const { filename, shortCode } = unicodeMapping[match]; const title = shortCode ? `:${shortCode}:` : ''; replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`; @@ -93,8 +93,11 @@ export const buildCustomEmojis = (customEmojis) => { keywords: [name], imageUrl: url, custom: true, + customCategory: emoji.get('category'), }); }); return emojis; }; + +export const categoriesFromEmojis = customEmojis => customEmojis.reduce((set, emoji) => set.add(emoji.get('category') ? `custom-${emoji.get('category')}` : 'custom'), new Set(['custom'])); diff --git a/app/javascript/flavours/glitch/util/idna.js b/app/javascript/flavours/glitch/util/idna.js new file mode 100644 index 000000000..efab5bacf --- /dev/null +++ b/app/javascript/flavours/glitch/util/idna.js @@ -0,0 +1,10 @@ +import punycode from 'punycode'; + +const IDNA_PREFIX = 'xn--'; + +export const decode = domain => { + return domain + .split('.') + .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) + .join('.'); +}; diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js index f42c06a3a..911468e6f 100644 --- a/app/javascript/flavours/glitch/util/initial_state.js +++ b/app/javascript/flavours/glitch/util/initial_state.js @@ -26,8 +26,13 @@ export const pollLimits = (initialState && initialState.poll_limits); export const invitesEnabled = getMeta('invites_enabled'); export const version = getMeta('version'); export const mascot = getMeta('mascot'); +export const profile_directory = getMeta('profile_directory'); export const isStaff = getMeta('is_staff'); export const defaultContentType = getMeta('default_content_type'); export const forceSingleColumn = getMeta('advanced_layout') === false; +export const useBlurhash = getMeta('use_blurhash'); +export const usePendingItems = getMeta('use_pending_items'); +export const useSystemEmojiFont = getMeta('system_emoji_font'); +export const showTrends = getMeta('trends'); export default initialState; diff --git a/app/javascript/flavours/glitch/util/load_keyboard_extensions.js b/app/javascript/flavours/glitch/util/load_keyboard_extensions.js new file mode 100644 index 000000000..2dd0e45fa --- /dev/null +++ b/app/javascript/flavours/glitch/util/load_keyboard_extensions.js @@ -0,0 +1,16 @@ +// On KaiOS, we may not be able to use a mouse cursor or navigate using Tab-based focus, so we install +// special left/right focus navigation keyboard listeners, at least on public pages (i.e. so folks +// can at least log in using KaiOS devices). + +function importArrowKeyNavigation() { + return import(/* webpackChunkName: "arrow-key-navigation" */ 'arrow-key-navigation'); +} + +export default function loadKeyboardExtensions() { + if (/KAIOS/.test(navigator.userAgent)) { + return importArrowKeyNavigation().then(arrowKeyNav => { + arrowKeyNav.register(); + }); + } + return Promise.resolve(); +} diff --git a/app/javascript/flavours/glitch/util/log_out.js b/app/javascript/flavours/glitch/util/log_out.js new file mode 100644 index 000000000..8e1659293 --- /dev/null +++ b/app/javascript/flavours/glitch/util/log_out.js @@ -0,0 +1,34 @@ +import Rails from 'rails-ujs'; +import { signOutLink } from 'flavours/glitch/util/backend_links'; + +export const logOut = () => { + const form = document.createElement('form'); + + const methodInput = document.createElement('input'); + methodInput.setAttribute('name', '_method'); + methodInput.setAttribute('value', 'delete'); + methodInput.setAttribute('type', 'hidden'); + form.appendChild(methodInput); + + const csrfToken = Rails.csrfToken(); + const csrfParam = Rails.csrfParam(); + + if (csrfParam && csrfToken) { + const csrfInput = document.createElement('input'); + csrfInput.setAttribute('name', csrfParam); + csrfInput.setAttribute('value', csrfToken); + csrfInput.setAttribute('type', 'hidden'); + form.appendChild(csrfInput); + } + + const submitButton = document.createElement('input'); + submitButton.setAttribute('type', 'submit'); + form.appendChild(submitButton); + + form.method = 'post'; + form.action = signOutLink; + form.style.display = 'none'; + + document.body.appendChild(form); + submitButton.click(); +}; diff --git a/app/javascript/flavours/glitch/util/numbers.js b/app/javascript/flavours/glitch/util/numbers.js index fdd8269ae..f7e4ceb93 100644 --- a/app/javascript/flavours/glitch/util/numbers.js +++ b/app/javascript/flavours/glitch/util/numbers.js @@ -4,7 +4,9 @@ import { FormattedNumber } from 'react-intl'; export const shortNumberFormat = number => { if (number < 1000) { return <FormattedNumber value={number} />; - } else { + } else if (number < 1000000) { return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</Fragment>; + } else { + return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={1} />M</Fragment>; } }; diff --git a/app/javascript/flavours/glitch/util/resize_image.js b/app/javascript/flavours/glitch/util/resize_image.js index bbdbc865e..d566edb03 100644 --- a/app/javascript/flavours/glitch/util/resize_image.js +++ b/app/javascript/flavours/glitch/util/resize_image.js @@ -67,6 +67,14 @@ const processImage = (img, { width, height, orientation, type = 'image/png' }) = context.drawImage(img, 0, 0, width, height); + // The Tor Browser and maybe other browsers may prevent reading from canvas + // and return an all-white image instead. Assume reading failed if the resized + // image is perfectly white. + const imageData = context.getImageData(0, 0, width, height); + if (imageData.data.every(value => value === 255)) { + throw 'Failed to read from canvas'; + } + canvas.toBlob(resolve, type); }); diff --git a/app/javascript/flavours/glitch/util/rtl.js b/app/javascript/flavours/glitch/util/rtl.js index 00870a15d..89bed6de8 100644 --- a/app/javascript/flavours/glitch/util/rtl.js +++ b/app/javascript/flavours/glitch/util/rtl.js @@ -20,6 +20,7 @@ export function isRtl(text) { text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, ''); text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, ''); text = text.replace(/\s+/g, ''); + text = text.replace(/(\w\S+\.\w{2,}\S*)/g, ''); const matches = text.match(rtlChars); diff --git a/app/javascript/flavours/glitch/util/scroll.js b/app/javascript/flavours/glitch/util/scroll.js index 2af07e0fb..84fe58269 100644 --- a/app/javascript/flavours/glitch/util/scroll.js +++ b/app/javascript/flavours/glitch/util/scroll.js @@ -26,5 +26,7 @@ const scroll = (node, key, target) => { }; }; -export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position); -export const scrollTop = (node) => scroll(node, 'scrollTop', 0); +const isScrollBehaviorSupported = 'scrollBehavior' in document.documentElement.style; + +export const scrollRight = (node, position) => isScrollBehaviorSupported ? node.scrollTo({ left: position, behavior: 'smooth' }) : scroll(node, 'scrollLeft', position); +export const scrollTop = (node) => isScrollBehaviorSupported ? node.scrollTo({ top: 0, behavior: 'smooth' }) : scroll(node, 'scrollTop', 0); diff --git a/app/javascript/flavours/glitch/util/scrollbar.js b/app/javascript/flavours/glitch/util/scrollbar.js new file mode 100644 index 000000000..929b036d6 --- /dev/null +++ b/app/javascript/flavours/glitch/util/scrollbar.js @@ -0,0 +1,34 @@ +/** @type {number | null} */ +let cachedScrollbarWidth = null; + +/** + * @return {number} + */ +const getActualScrollbarWidth = () => { + const outer = document.createElement('div'); + outer.style.visibility = 'hidden'; + outer.style.overflow = 'scroll'; + document.body.appendChild(outer); + + const inner = document.createElement('div'); + outer.appendChild(inner); + + const scrollbarWidth = outer.offsetWidth - inner.offsetWidth; + outer.parentNode.removeChild(outer); + + return scrollbarWidth; +}; + +/** + * @return {number} + */ +export const getScrollbarWidth = () => { + if (cachedScrollbarWidth !== null) { + return cachedScrollbarWidth; + } + + const scrollbarWidth = getActualScrollbarWidth(); + cachedScrollbarWidth = scrollbarWidth; + + return scrollbarWidth; +}; diff --git a/app/javascript/flavours/glitch/util/stream.js b/app/javascript/flavours/glitch/util/stream.js index c4642344f..50f90d44c 100644 --- a/app/javascript/flavours/glitch/util/stream.js +++ b/app/javascript/flavours/glitch/util/stream.js @@ -1,4 +1,4 @@ -import WebSocketClient from 'websocket.js'; +import WebSocketClient from '@gamestdio/websocket'; const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max)); |