Merge tag 'v3.0.0' into hometown-dev
This commit is contained in:
@ -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}`;
|
||||
|
||||
|
10
app/javascript/mastodon/actions/app.js
Normal file
10
app/javascript/mastodon/actions/app.js
Normal file
@ -0,0 +1,10 @@
|
||||
export const APP_FOCUS = 'APP_FOCUS';
|
||||
export const APP_UNFOCUS = 'APP_UNFOCUS';
|
||||
|
||||
export const focusApp = () => ({
|
||||
type: APP_FOCUS,
|
||||
});
|
||||
|
||||
export const unfocusApp = () => ({
|
||||
type: APP_UNFOCUS,
|
||||
});
|
@ -1,6 +1,7 @@
|
||||
import api, { getLinks } from '../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'));
|
||||
};
|
||||
}
|
||||
|
@ -11,7 +11,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_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
||||
@ -236,7 +236,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)));
|
||||
};
|
||||
};
|
||||
@ -291,10 +291,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,
|
||||
};
|
||||
};
|
||||
@ -327,10 +328,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,
|
||||
@ -351,9 +354,33 @@ 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,
|
||||
exclude_unreviewed: true,
|
||||
},
|
||||
}).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) => {
|
||||
@ -387,20 +414,26 @@ 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, startPosition;
|
||||
|
||||
if (typeof suggestion === 'object' && suggestion.id) {
|
||||
if (suggestion.type === 'emoji') {
|
||||
completion = suggestion.native || suggestion.colons;
|
||||
startPosition = position - 1;
|
||||
|
||||
dispatch(useEmoji(suggestion));
|
||||
} else if (suggestion[0] === '#') {
|
||||
completion = suggestion;
|
||||
} else if (suggestion.type === 'hashtag') {
|
||||
completion = `#${suggestion.name}`;
|
||||
startPosition = position - 1;
|
||||
} else {
|
||||
completion = getState().getIn(['accounts', suggestion, 'acct']);
|
||||
} else if (suggestion.type === 'account') {
|
||||
completion = getState().getIn(['accounts', suggestion.id, 'acct']);
|
||||
startPosition = position;
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
});
|
||||
|
61
app/javascript/mastodon/actions/directory.js
Normal file
61
app/javascript/mastodon/actions/directory.js
Normal file
@ -0,0 +1,61 @@
|
||||
import api from '../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,
|
||||
});
|
@ -73,8 +73,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),
|
||||
}));
|
||||
|
||||
|
30
app/javascript/mastodon/actions/markers.js
Normal file
30
app/javascript/mastodon/actions/markers.js
Normal file
@ -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));
|
||||
};
|
@ -12,6 +12,8 @@ import { defineMessages } from 'react-intl';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import { unescapeHTML } from '../utils/html';
|
||||
import { getFiltersRegex } from '../selectors';
|
||||
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
||||
import compareId from 'mastodon/compare_id';
|
||||
|
||||
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
|
||||
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
|
||||
@ -22,8 +24,12 @@ 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';
|
||||
|
||||
defineMessages({
|
||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
||||
@ -38,6 +44,10 @@ 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);
|
||||
@ -69,6 +79,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||
dispatch({
|
||||
type: NOTIFICATIONS_UPDATE,
|
||||
notification,
|
||||
usePendingItems: preferPendingItems,
|
||||
meta: (playSound && !filtered) ? { sound: 'boop' } : undefined,
|
||||
});
|
||||
|
||||
@ -122,10 +133,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 => {
|
||||
@ -134,7 +154,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 => {
|
||||
@ -151,11 +171,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,
|
||||
next,
|
||||
isLoadingRecent: isLoadingRecent,
|
||||
usePendingItems,
|
||||
skipLoading: !isLoadingMore,
|
||||
};
|
||||
};
|
||||
@ -196,3 +218,11 @@ export function setFilter (filterType) {
|
||||
dispatch(saveSettings());
|
||||
};
|
||||
};
|
||||
|
||||
export const mountNotifications = () => ({
|
||||
type: NOTIFICATIONS_MOUNT,
|
||||
});
|
||||
|
||||
export const unmountNotifications = () => ({
|
||||
type: NOTIFICATIONS_UNMOUNT,
|
||||
});
|
||||
|
@ -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,
|
||||
@ -77,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,
|
||||
});
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
import api, { getLinks } from '../api';
|
||||
import api, { getLinks } from 'mastodon/api';
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
import compareId from 'mastodon/compare_id';
|
||||
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
||||
|
||||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
||||
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
|
||||
@ -10,10 +12,15 @@ 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 => {
|
||||
@ -27,6 +34,7 @@ export function updateTimeline(timeline, status, accept) {
|
||||
type: TIMELINE_UPDATE,
|
||||
timeline,
|
||||
status,
|
||||
usePendingItems: preferPendingItems,
|
||||
});
|
||||
};
|
||||
};
|
||||
@ -71,8 +79,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;
|
||||
@ -82,7 +97,7 @@ 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.code === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
||||
done();
|
||||
}).catch(error => {
|
||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
||||
@ -115,7 +130,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,
|
||||
@ -123,6 +138,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadi
|
||||
next,
|
||||
partial,
|
||||
isLoadingRecent,
|
||||
usePendingItems,
|
||||
skipLoading: !isLoadingMore,
|
||||
};
|
||||
};
|
||||
@ -151,9 +167,8 @@ export function connectTimeline(timeline) {
|
||||
};
|
||||
};
|
||||
|
||||
export function disconnectTimeline(timeline) {
|
||||
return {
|
||||
type: TIMELINE_DISCONNECT,
|
||||
timeline,
|
||||
};
|
||||
};
|
||||
export const disconnectTimeline = timeline => ({
|
||||
type: TIMELINE_DISCONNECT,
|
||||
timeline,
|
||||
usePendingItems: preferPendingItems,
|
||||
});
|
||||
|
32
app/javascript/mastodon/actions/trends.js
Normal file
32
app/javascript/mastodon/actions/trends.js
Normal file
@ -0,0 +1,32 @@
|
||||
import api from '../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,
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
28
app/javascript/mastodon/components/autosuggest_hashtag.js
Normal file
28
app/javascript/mastodon/components/autosuggest_hashtag.js
Normal file
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { shortNumberFormat } from 'mastodon/utils/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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import AutosuggestAccountContainer from '../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 '../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 (
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import AutosuggestAccountContainer from '../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 '../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 (
|
||||
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,7 +88,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>
|
||||
);
|
||||
}
|
||||
|
@ -8,10 +8,11 @@ export default class Column extends React.PureComponent {
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
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;
|
||||
@ -33,11 +34,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 () {
|
||||
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export default class ColumnBackButton extends React.PureComponent {
|
||||
|
||||
@ -9,6 +10,10 @@ export default class ColumnBackButton extends React.PureComponent {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
if (window.history && window.history.length === 1) {
|
||||
this.context.router.history.push('/');
|
||||
@ -18,12 +23,32 @@ export default class ColumnBackButton extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
const { multiColumn } = this.props;
|
||||
|
||||
const component = (
|
||||
<button onClick={this.handleClick} className='column-back-button'>
|
||||
<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createPortal } from 'react-dom';
|
||||
import classNames from 'classnames';
|
||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
@ -28,6 +29,7 @@ class ColumnHeader extends React.PureComponent {
|
||||
showBackButton: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
pinned: PropTypes.bool,
|
||||
placeholder: PropTypes.bool,
|
||||
onPin: PropTypes.func,
|
||||
onMove: PropTypes.func,
|
||||
onClick: PropTypes.func,
|
||||
@ -79,7 +81,7 @@ class ColumnHeader extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage } } = this.props;
|
||||
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder } = this.props;
|
||||
const { collapsed, animating } = this.state;
|
||||
|
||||
const wrapperClassName = classNames('column-header__wrapper', {
|
||||
@ -118,7 +120,7 @@ class ColumnHeader extends React.PureComponent {
|
||||
<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) {
|
||||
} 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>;
|
||||
}
|
||||
|
||||
@ -140,13 +142,13 @@ class ColumnHeader extends React.PureComponent {
|
||||
collapsedContent.push(pinButton);
|
||||
}
|
||||
|
||||
if (children || multiColumn) {
|
||||
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 && (
|
||||
@ -172,6 +174,24 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { version, source_url } from 'mastodon/initial_state';
|
||||
|
||||
export default class ErrorBoundary extends React.PureComponent {
|
||||
|
||||
@ -11,26 +13,53 @@ export default class ErrorBoundary extends React.PureComponent {
|
||||
hasError: false,
|
||||
stackTrace: undefined,
|
||||
componentStack: undefined,
|
||||
}
|
||||
};
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
componentDidCatch (error, info) {
|
||||
this.setState({
|
||||
hasError: true,
|
||||
stackTrace: error.stack,
|
||||
componentStack: info && info.componentStack,
|
||||
copied: false,
|
||||
});
|
||||
}
|
||||
|
||||
handleCopyStackTrace = () => {
|
||||
const { stackTrace } = this.state;
|
||||
const textarea = document.createElement('textarea');
|
||||
|
||||
textarea.textContent = stackTrace;
|
||||
textarea.style.position = 'fixed';
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
try {
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
} catch (e) {
|
||||
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
this.setState({ copied: true });
|
||||
setTimeout(() => this.setState({ copied: false }), 700);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { hasError } = this.state;
|
||||
const { hasError, copied } = this.state;
|
||||
|
||||
if (!hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="hometownlogo" x="0px" y="0px" viewBox="25 40 50 20" width="100%" height="100%"><g><path d="M55.9,53.9H35.3c-0.7,0-1.3,0.6-1.3,1.3s0.6,1.3,1.3,1.3h20.6c0.7,0,1.3-0.6,1.3-1.3S56.6,53.9,55.9,53.9z"/><path d="M55.9,58.2H35.3c-0.7,0-1.3,0.6-1.3,1.3s0.6,1.3,1.3,1.3h20.6c0.7,0,1.3-0.6,1.3-1.3S56.6,58.2,55.9,58.2z"/><path d="M55.9,62.6H35.3c-0.7,0-1.3,0.6-1.3,1.3s0.6,1.3,1.3,1.3h20.6c0.7,0,1.3-0.6,1.3-1.3S56.6,62.6,55.9,62.6z"/><path d="M64.8,53.9c-0.7,0-1.3,0.6-1.3,1.3v8.8c0,0.7,0.6,1.3,1.3,1.3s1.3-0.6,1.3-1.3v-8.8C66,54.4,65.4,53.9,64.8,53.9z"/><path d="M60.4,53.9c-0.7,0-1.3,0.6-1.3,1.3v8.8c0,0.7,0.6,1.3,1.3,1.3s1.3-0.6,1.3-1.3v-8.8C61.6,54.4,61.1,53.9,60.4,53.9z"/><path d="M63.7,48.3c1.3-0.7,2-2.5,2-5.6c0-3.6-0.9-7.8-3.3-7.8s-3.3,4.2-3.3,7.8c0,3.1,0.7,4.9,2,5.6v2.4c0,0.7,0.6,1.3,1.3,1.3 s1.3-0.6,1.3-1.3V48.3z M62.4,37.8c0.4,0.8,0.8,2.5,0.8,4.9c0,2.5-0.5,3.4-0.8,3.4s-0.8-0.9-0.8-3.4C61.7,40.3,62.1,38.6,62.4,37.8 z"/><path d="M57,42.7c0-0.1-0.1-0.1-0.1-0.2l-3.2-4.1c-0.2-0.3-0.6-0.5-1-0.5h-1.6v-1.9c0-0.7-0.6-1.3-1.3-1.3s-1.3,0.6-1.3,1.3V38 h-3.9h-1.1h-5.2c-0.4,0-0.7,0.2-1,0.5l-3.2,4.1c0,0.1-0.1,0.1-0.1,0.2c0,0-0.1,0.1-0.1,0.1C34,43,34,43.2,34,43.3v7.4 c0,0.7,0.6,1.3,1.3,1.3h5.2h7.4h8c0.7,0,1.3-0.6,1.3-1.3v-7.4c0-0.2,0-0.3-0.1-0.4C57,42.8,57,42.8,57,42.7z M41.7,49.5h-5.2v-4.9 h10.2v4.9H41.7z M48.5,42.1l-1.2-1.6h4.8l1.2,1.6H48.5z M44.1,40.5l1.2,1.6h-7.5l1.2-1.6H44.1z M49.2,44.6h5.5v4.9h-5.5V44.6z"/></g></svg>
|
||||
<div className='error-boundary'>
|
||||
<div>
|
||||
<p className='error-boundary__error'><FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /></p>
|
||||
<p><FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /></p>
|
||||
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied && 'copied'}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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'>
|
||||
|
22
app/javascript/mastodon/components/load_pending.js
Normal file
22
app/javascript/mastodon/components/load_pending.js
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -6,7 +6,7 @@ import IconButton from './icon_button';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { isIOS } from '../is_mobile';
|
||||
import classNames from 'classnames';
|
||||
import { autoPlayGif, displayMedia } from '../initial_state';
|
||||
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
|
||||
import { decode } from 'blurhash';
|
||||
|
||||
const messages = defineMessages({
|
||||
@ -81,6 +81,8 @@ class Item extends React.PureComponent {
|
||||
}
|
||||
|
||||
_decode () {
|
||||
if (!useBlurhash) return;
|
||||
|
||||
const hash = this.props.attachment.get('blurhash');
|
||||
const pixels = decode(hash, 32, 32);
|
||||
|
||||
@ -157,7 +159,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' }} title={attachment.get('description')}>
|
||||
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} target='_blank' style={{ cursor: 'pointer' }} title={attachment.get('description')}>
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
|
||||
</a>
|
||||
</div>
|
||||
@ -313,15 +315,22 @@ class MediaGallery extends React.PureComponent {
|
||||
style.height = height;
|
||||
}
|
||||
|
||||
const size = media.take(4).size;
|
||||
const size = media.take(4).size;
|
||||
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
|
||||
|
||||
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} 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} 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 = (
|
||||
@ -333,7 +342,7 @@ class MediaGallery extends React.PureComponent {
|
||||
|
||||
return (
|
||||
<div className='media-gallery' 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}
|
||||
</div>
|
||||
|
||||
|
@ -10,9 +10,11 @@ import spring from 'react-motion/lib/spring';
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import emojify from 'mastodon/features/emoji/emoji';
|
||||
import RelativeTimestamp from './relative_timestamp';
|
||||
import Icon from 'mastodon/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,8 +34,38 @@ class Poll extends ImmutablePureComponent {
|
||||
|
||||
state = {
|
||||
selected: {},
|
||||
expired: null,
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps (props, state) {
|
||||
const { poll, intl } = props;
|
||||
const expired = poll.get('expired') || (new Date(poll.get('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);
|
||||
}
|
||||
}
|
||||
|
||||
handleOptionChange = e => {
|
||||
const { target: { value } } = e;
|
||||
|
||||
@ -68,12 +100,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) {
|
||||
@ -102,7 +135,10 @@ class Poll extends ImmutablePureComponent {
|
||||
/>
|
||||
|
||||
{!showResults && <span className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} />}
|
||||
{showResults && <span className='poll__number'>{Math.round(percent)}%</span>}
|
||||
{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 +148,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>
|
||||
|
35
app/javascript/mastodon/components/radio_button.js
Normal file
35
app/javascript/mastodon/components/radio_button.js
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -3,6 +3,7 @@ import { ScrollContainer } from 'react-router-scroll-4';
|
||||
import PropTypes from 'prop-types';
|
||||
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
||||
import LoadMore from './load_more';
|
||||
import LoadPending from './load_pending';
|
||||
import IntersectionObserverWrapper from '../features/ui/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.clearMouseIdleTimer();
|
||||
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;
|
||||
}
|
||||
@ -132,15 +143,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);
|
||||
}
|
||||
@ -149,9 +172,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);
|
||||
const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0);
|
||||
|
||||
if (someItemInserted && (this.node.scrollTop > 0 || this.mouseMovedRecently)) {
|
||||
return this.node.scrollHeight - this.node.scrollTop;
|
||||
if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently)) {
|
||||
return this.getScrollHeight() - this.getScrollTop();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@ -161,7 +185,7 @@ export default class ScrollableList extends PureComponent {
|
||||
// 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.setScrollTop(this.node.scrollHeight - snapshot);
|
||||
this.setScrollTop(this.getScrollHeight() - snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@ -175,6 +199,7 @@ export default class ScrollableList extends PureComponent {
|
||||
this.clearMouseIdleTimer();
|
||||
this.detachScrollListener();
|
||||
this.detachIntersectionObserver();
|
||||
|
||||
detachFullscreenListener(this.onFullScreenChange);
|
||||
}
|
||||
|
||||
@ -194,13 +219,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) {
|
||||
@ -225,12 +260,25 @@ export default class ScrollableList extends PureComponent {
|
||||
this.props.onLoadMore();
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -251,6 +299,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}
|
||||
|
@ -12,7 +12,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 '../features/ui/util/async-components';
|
||||
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
@ -199,11 +199,15 @@ class Status extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
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' }} />;
|
||||
}
|
||||
|
||||
handleOpenVideo = (media, startTime) => {
|
||||
@ -278,12 +282,27 @@ class Status extends ImmutablePureComponent {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlers = this.props.muted ? {} : {
|
||||
reply: this.handleHotkeyReply,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
boost: this.handleHotkeyBoost,
|
||||
mention: this.handleHotkeyMention,
|
||||
open: this.handleHotkeyOpen,
|
||||
openProfile: this.handleHotkeyOpenProfile,
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
toggleHidden: this.handleHotkeyToggleHidden,
|
||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||
};
|
||||
|
||||
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={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex='0'>
|
||||
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
|
||||
{status.get('content')}
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
@ -333,7 +352,23 @@ class Status extends ImmutablePureComponent {
|
||||
media={status.get('media_attachments')}
|
||||
/>
|
||||
);
|
||||
} else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
|
||||
} else if (status.getIn(['media_attachments', 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>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
@ -394,19 +429,6 @@ class Status extends ImmutablePureComponent {
|
||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||
}
|
||||
|
||||
const handlers = this.props.muted ? {} : {
|
||||
reply: this.handleHotkeyReply,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
boost: this.handleHotkeyBoost,
|
||||
mention: this.handleHotkeyMention,
|
||||
open: this.handleHotkeyOpen,
|
||||
openProfile: this.handleHotkeyOpenProfile,
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
toggleHidden: this.handleHotkeyToggleHidden,
|
||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||
};
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-type-${status.get('activity_pub_type') || 'none'}` , `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
||||
|
@ -55,6 +55,7 @@ export default class StatusContent extends React.PureComponent {
|
||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||
} else {
|
||||
link.setAttribute('title', link.href);
|
||||
link.classList.add('unhandled-link');
|
||||
}
|
||||
|
||||
link.setAttribute('target', '_blank');
|
||||
@ -111,7 +112,7 @@ export default class StatusContent extends React.PureComponent {
|
||||
}
|
||||
|
||||
onHashtagClick = (hashtag, e) => {
|
||||
hashtag = hashtag.replace(/^#/, '').toLowerCase();
|
||||
hashtag = hashtag.replace(/^#/, '');
|
||||
|
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
@ -240,7 +241,7 @@ export default class StatusContent extends React.PureComponent {
|
||||
return output;
|
||||
} else if (this.props.onClick) {
|
||||
const output = [
|
||||
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
|
||||
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
|
||||
|
||||
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||
|
@ -12,6 +12,8 @@ import { hydrateStore } from '../actions/store';
|
||||
import { connectUserStream } from '../actions/streaming';
|
||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||
import { getLocale } from '../locales';
|
||||
import { previewState as previewMediaState } from 'mastodon/features/ui/components/media_modal';
|
||||
import { previewState as previewVideoState } from 'mastodon/features/ui/components/video_modal';
|
||||
import initialState from '../initial_state';
|
||||
import ErrorBoundary from '../components/error_boundary';
|
||||
|
||||
@ -35,6 +37,10 @@ class MastodonMount extends React.PureComponent {
|
||||
showIntroduction: PropTypes.bool,
|
||||
};
|
||||
|
||||
shouldUpdateScroll (_, { location }) {
|
||||
return location.state !== previewMediaState && location.state !== previewVideoState;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { showIntroduction } = this.props;
|
||||
|
||||
@ -44,7 +50,7 @@ class MastodonMount extends React.PureComponent {
|
||||
|
||||
return (
|
||||
<BrowserRouter basename='/web'>
|
||||
<ScrollContext>
|
||||
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
|
||||
<Route path='/' component={UI} />
|
||||
</ScrollContext>
|
||||
</BrowserRouter>
|
||||
|
@ -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 { getLocale } from '../locales';
|
||||
import MediaGallery from '../components/media_gallery';
|
||||
import Video from '../features/video';
|
||||
import Card from '../features/status/components/card';
|
||||
import Poll from 'mastodon/components/poll';
|
||||
import ModalRoot from '../components/modal_root';
|
||||
import MediaModal from '../features/ui/components/media_modal';
|
||||
import { List as ImmutableList, fromJS } from 'immutable';
|
||||
import { getLocale } from 'mastodon/locales';
|
||||
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
||||
import MediaGallery from 'mastodon/components/media_gallery';
|
||||
import Poll from 'mastodon/components/poll';
|
||||
import Hashtag from 'mastodon/components/hashtag';
|
||||
import ModalRoot from 'mastodon/components/modal_root';
|
||||
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
||||
import Video from 'mastodon/features/video';
|
||||
import Card from 'mastodon/features/status/components/card';
|
||||
import Audio from 'mastodon/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
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import Status from '../components/status';
|
||||
import { makeGetStatus } from '../selectors';
|
||||
@ -15,7 +14,6 @@ import {
|
||||
pin,
|
||||
unpin,
|
||||
} from '../actions/interactions';
|
||||
import { blockAccount } from '../actions/accounts';
|
||||
import {
|
||||
muteStatus,
|
||||
unmuteStatus,
|
||||
@ -24,9 +22,10 @@ import {
|
||||
revealStatus,
|
||||
} from '../actions/statuses';
|
||||
import { initMuteModal } from '../actions/mutes';
|
||||
import { initBlockModal } from '../actions/blocks';
|
||||
import { initReport } from '../actions/reports';
|
||||
import { openModal } from '../actions/modal';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { boostModal, deleteModal } from '../initial_state';
|
||||
import { showAlertForError } from '../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 = () => {
|
||||
@ -56,6 +53,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
onReply (status, router) {
|
||||
dispatch((_, getState) => {
|
||||
let state = getState();
|
||||
|
||||
if (state.getIn(['compose', 'text']).trim().length !== 0) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.replyMessage),
|
||||
@ -77,7 +75,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
},
|
||||
|
||||
onReblog (status, e) {
|
||||
if (e.shiftKey || !boostModal) {
|
||||
if ((e && e.shiftKey) || !boostModal) {
|
||||
this.onModalReblog(status);
|
||||
} else {
|
||||
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
|
||||
@ -137,16 +135,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) {
|
||||
|
@ -96,6 +96,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;
|
||||
@ -113,6 +119,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 })}>
|
||||
@ -129,7 +136,7 @@ export default class MediaItem extends ImmutablePureComponent {
|
||||
muted
|
||||
/>
|
||||
|
||||
<span className='media-gallery__gifv__label'>GIF</span>
|
||||
<span className='media-gallery__gifv__label'>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -56,6 +56,7 @@ class AccountGallery extends ImmutablePureComponent {
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
isAccount: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -101,6 +102,8 @@ 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'));
|
||||
@ -116,7 +119,7 @@ class AccountGallery extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
|
||||
const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn } = this.props;
|
||||
const { width } = this.state;
|
||||
|
||||
if (!isAccount) {
|
||||
@ -143,7 +146,7 @@ class AccountGallery extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
<ColumnBackButton multiColumn={multiColumn} />
|
||||
|
||||
<ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={shouldUpdateScroll}>
|
||||
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
|
||||
|
@ -5,7 +5,6 @@ import Header from '../components/header';
|
||||
import {
|
||||
followAccount,
|
||||
unfollowAccount,
|
||||
blockAccount,
|
||||
unblockAccount,
|
||||
unmuteAccount,
|
||||
pinAccount,
|
||||
@ -16,6 +15,7 @@ import {
|
||||
directCompose,
|
||||
} from '../../../actions/compose';
|
||||
import { initMuteModal } from '../../../actions/mutes';
|
||||
import { initBlockModal } from '../../../actions/blocks';
|
||||
import { initReport } from '../../../actions/reports';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
import { blockDomain, unblockDomain } from '../../../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));
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -44,6 +44,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||
withReplies: PropTypes.bool,
|
||||
blockedBy: PropTypes.bool,
|
||||
isAccount: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
@ -77,7 +78,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount } = this.props;
|
||||
const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn } = this.props;
|
||||
|
||||
if (!isAccount) {
|
||||
return (
|
||||
@ -99,7 +100,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
<ColumnBackButton multiColumn={multiColumn} />
|
||||
|
||||
<StatusList
|
||||
prepend={<HeaderContainer accountId={this.props.params.accountId} />}
|
||||
@ -112,6 +113,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||
onLoadMore={this.handleLoadMore}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
|
226
app/javascript/mastodon/features/audio/index.js
Normal file
226
app/javascript/mastodon/features/audio/index.js
Normal file
@ -0,0 +1,226 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { formatTime } from 'mastodon/features/video';
|
||||
import Icon from 'mastodon/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' },
|
||||
});
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -32,6 +32,7 @@ class Blocks extends ImmutablePureComponent {
|
||||
accountIds: ImmutablePropTypes.list,
|
||||
hasMore: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
@ -43,7 +44,7 @@ class Blocks extends ImmutablePureComponent {
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, accountIds, shouldUpdateScroll, hasMore } = this.props;
|
||||
const { intl, accountIds, shouldUpdateScroll, hasMore, multiColumn } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
@ -56,7 +57,7 @@ class Blocks extends ImmutablePureComponent {
|
||||
const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />;
|
||||
|
||||
return (
|
||||
<Column icon='ban' heading={intl.formatMessage(messages.heading)}>
|
||||
<Column bindToDocument={!multiColumn} icon='ban' heading={intl.formatMessage(messages.heading)}>
|
||||
<ColumnBackButtonSlim />
|
||||
<ScrollableList
|
||||
scrollKey='blocks'
|
||||
@ -64,6 +65,7 @@ class Blocks extends ImmutablePureComponent {
|
||||
hasMore={hasMore}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} />
|
||||
|
@ -20,7 +20,7 @@ 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>
|
||||
</div>
|
||||
);
|
||||
|
@ -18,9 +18,10 @@ const mapStateToProps = (state, { onlyMedia, columnId }) => {
|
||||
const uuid = columnId;
|
||||
const columns = state.getIn(['settings', 'columns']);
|
||||
const index = columns.findIndex(c => c.get('uuid') === uuid);
|
||||
const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]);
|
||||
|
||||
return {
|
||||
hasUnread: state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`, 'unread']) > 0,
|
||||
hasUnread: !!timelineState && (timelineState.get('unread') > 0 || timelineState.get('pendingItems').size > 0),
|
||||
onlyMedia: (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']),
|
||||
};
|
||||
};
|
||||
@ -105,7 +106,7 @@ class CommunityTimeline extends React.PureComponent {
|
||||
const pinned = !!columnId;
|
||||
|
||||
return (
|
||||
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
icon='users'
|
||||
active={hasUnread}
|
||||
@ -126,6 +127,7 @@ class CommunityTimeline extends React.PureComponent {
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
|
@ -23,9 +23,14 @@ class ActionBar extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleLogout = () => {
|
||||
this.props.onLogout();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
|
||||
@ -44,7 +49,7 @@ class ActionBar extends React.PureComponent {
|
||||
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
|
||||
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.logout), href: '/auth/sign_out', target: null, method: 'delete' });
|
||||
menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
|
||||
|
||||
return (
|
||||
<div className='compose__action-bar'>
|
||||
|
@ -6,7 +6,7 @@ import Overlay from 'react-overlays/lib/Overlay';
|
||||
import classNames from 'classnames';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
import { buildCustomEmojis } from '../../emoji/emoji';
|
||||
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
|
||||
|
||||
const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
@ -31,19 +31,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 = {
|
||||
@ -241,8 +228,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
|
||||
|
@ -12,6 +12,7 @@ export default class NavigationBar extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
@ -33,7 +34,7 @@ export default class NavigationBar extends ImmutablePureComponent {
|
||||
|
||||
<div className='navigation-bar__actions'>
|
||||
<IconButton className='close' title='' icon='close' onClick={this.props.onClose} />
|
||||
<ActionBar account={this.props.account} />
|
||||
<ActionBar account={this.props.account} onLogout={this.props.onLogout} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -60,12 +60,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) => {
|
||||
this.props.onChange(e.target.value);
|
||||
}
|
||||
@ -95,6 +100,13 @@ class Search extends React.PureComponent {
|
||||
handleFocus = () => {
|
||||
this.setState({ expanded: true });
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleBlur = () => {
|
||||
@ -111,6 +123,7 @@ class Search extends React.PureComponent {
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
|
||||
<input
|
||||
ref={this.setRef}
|
||||
className='search__input'
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
|
@ -8,6 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Hashtag from '../../../components/hashtag';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { searchEnabled } from '../../../initial_state';
|
||||
import LoadMore from 'mastodon/components/load_more';
|
||||
|
||||
const messages = defineMessages({
|
||||
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
|
||||
@ -20,15 +21,24 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadMoreAccounts = () => this.props.expandSearch('accounts');
|
||||
|
||||
handleLoadMoreStatuses = () => this.props.expandSearch('statuses');
|
||||
|
||||
handleLoadMoreHashtags = () => this.props.expandSearch('hashtags');
|
||||
|
||||
render () {
|
||||
const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
|
||||
|
||||
@ -65,6 +75,8 @@ class SearchResults extends ImmutablePureComponent {
|
||||
<h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
|
||||
|
||||
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
|
||||
|
||||
{results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -76,6 +88,18 @@ class SearchResults extends ImmutablePureComponent {
|
||||
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5>
|
||||
|
||||
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
|
||||
|
||||
{results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />}
|
||||
</div>
|
||||
);
|
||||
} else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
|
||||
statuses = (
|
||||
<div className='search-results__section'>
|
||||
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5>
|
||||
|
||||
<div className='search-results__info'>
|
||||
<FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching posts by their content is not enabled on this server.' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
|
||||
@ -97,6 +121,8 @@ class SearchResults extends ImmutablePureComponent {
|
||||
<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} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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,14 @@ 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>
|
||||
);
|
||||
|
@ -4,16 +4,11 @@ import PropTypes from 'prop-types';
|
||||
import Motion from '../../ui/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 'mastodon/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Upload extends ImmutablePureComponent {
|
||||
export default class Upload extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
@ -21,30 +16,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'));
|
||||
@ -55,69 +30,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;
|
||||
const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
|
||||
const { media } = this.props;
|
||||
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='compose-form__upload' tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'>
|
||||
<div className='compose-form__upload' tabIndex='0' role='button'>
|
||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||
{({ scale }) => (
|
||||
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
|
||||
<div className={classNames('compose-form__upload__actions', { active })}>
|
||||
<div className={classNames('compose-form__upload__actions', { active: true })}>
|
||||
<button className='icon-button' onClick={this.handleUndoClick}><Icon id='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('compose-form__upload-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>
|
||||
<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -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 {
|
||||
|
||||
@ -16,7 +17,7 @@ export default class UploadForm extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<div className='compose-form__upload-wrapper'>
|
||||
<UploadProgressContainer />
|
||||
<UploadProgressContainer icon='upload' message={<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />} />
|
||||
|
||||
<div className='compose-form__uploads-wrapper'>
|
||||
{mediaIds.map(id => (
|
||||
|
@ -2,7 +2,6 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Motion from '../../ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import Icon from 'mastodon/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;
|
||||
@ -22,11 +23,11 @@ export default class UploadProgress extends React.PureComponent {
|
||||
return (
|
||||
<div className='upload-progress'>
|
||||
<div className='upload-progress__icon'>
|
||||
<Icon id='upload' />
|
||||
<Icon id={icon} />
|
||||
</div>
|
||||
|
||||
<div className='upload-progress__message'>
|
||||
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
|
||||
{message}
|
||||
|
||||
<div className='upload-progress__backdrop'>
|
||||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
|
||||
|
@ -1,11 +1,29 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import NavigationBar from '../components/navigation_bar';
|
||||
import { logOut } from 'mastodon/utils/log_out';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { me } from '../../../initial_state';
|
||||
|
||||
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 {
|
||||
account: state.getIn(['accounts', me]),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(NavigationBar);
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
onLogout () {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.logoutMessage),
|
||||
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||
onConfirm: () => logOut(),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar));
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { connect } from 'react-redux';
|
||||
import SearchResults from '../components/search_results';
|
||||
import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestions';
|
||||
import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
|
||||
import { expandSearch } from 'mastodon/actions/search';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
results: state.getIn(['search', 'results']),
|
||||
@ -10,6 +11,7 @@ const mapStateToProps = state => ({
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
fetchSuggestions: () => dispatch(fetchSuggestions()),
|
||||
expandSearch: type => dispatch(expandSearch(type)),
|
||||
dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
|
||||
});
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { connect } from 'react-redux';
|
||||
import Upload from '../components/upload';
|
||||
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
|
||||
import { undoUploadCompose } from '../../../actions/compose';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
import { submitCompose } from '../../../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 }));
|
||||
},
|
||||
|
@ -12,8 +12,11 @@ import Motion from '../ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import SearchResultsContainer from './containers/search_results_container';
|
||||
import { changeComposing } from '../../actions/compose';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
|
||||
import { mascot } from '../../initial_state';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { logOut } from 'mastodon/utils/log_out';
|
||||
|
||||
const messages = defineMessages({
|
||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
@ -24,6 +27,8 @@ const messages = defineMessages({
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
||||
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, ownProps) => ({
|
||||
@ -67,6 +72,21 @@ class Compose extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleLogoutClick = e => {
|
||||
const { dispatch, intl } = this.props;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.logoutMessage),
|
||||
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||
onConfirm: () => logOut(),
|
||||
}));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
onFocus = () => {
|
||||
this.props.dispatch(changeComposing(true));
|
||||
}
|
||||
@ -98,7 +118,7 @@ class Compose extends React.PureComponent {
|
||||
<Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' fixedWidth /></Link>
|
||||
)}
|
||||
<a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' fixedWidth /></a>
|
||||
<a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><Icon id='sign-out' fixedWidth /></a>
|
||||
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' fixedWidth /></a>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
@ -2,9 +2,29 @@ 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 '../../../containers/status_container';
|
||||
import StatusContent from 'mastodon/components/status_content';
|
||||
import AttachmentList from 'mastodon/components/attachment_list';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import AvatarComposite from 'mastodon/components/avatar_composite';
|
||||
import Permalink from 'mastodon/components/permalink';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import { autoPlayGif } from 'mastodon/initial_state';
|
||||
|
||||
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 +33,76 @@ 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,
|
||||
};
|
||||
|
||||
_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 +113,92 @@ export default class Conversation extends ImmutablePureComponent {
|
||||
this.props.onMoveDown(this.props.conversationId);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { accounts, lastStatusId, unread } = this.props;
|
||||
handleConversationMute = () => {
|
||||
this.props.onMute(this.props.lastStatus);
|
||||
}
|
||||
|
||||
if (lastStatusId === null) {
|
||||
handleShowMore = () => {
|
||||
this.props.onToggleHidden(this.props.lastStatus);
|
||||
}
|
||||
|
||||
setNamesRef = (c) => {
|
||||
this.namesNode = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { accounts, lastStatus, unread, intl } = this.props;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
return (
|
||||
<StatusContainer
|
||||
id={lastStatusId}
|
||||
unread={unread}
|
||||
otherAccounts={accounts}
|
||||
onMoveUp={this.handleHotkeyMoveUp}
|
||||
onMoveDown={this.handleHotkeyMoveDown}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className='conversation focusable muted' 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'>
|
||||
<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}
|
||||
onClick={this.handleClick}
|
||||
expanded={!lastStatus.get('hidden')}
|
||||
onExpandedToggle={this.handleShowMore}
|
||||
collapsable
|
||||
/>
|
||||
|
||||
{lastStatus.get('media_attachments').size > 0 && (
|
||||
<AttachmentList
|
||||
compact
|
||||
media={lastStatus.get('media_attachments')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,19 +1,74 @@
|
||||
import { connect } from 'react-redux';
|
||||
import Conversation from '../components/conversation';
|
||||
import { markConversationRead } from '../../../actions/conversations';
|
||||
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
|
||||
import { makeGetStatus } from 'mastodon/selectors';
|
||||
import { replyCompose } from 'mastodon/actions/compose';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'mastodon/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?' },
|
||||
});
|
||||
|
||||
return {
|
||||
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
|
||||
unread: conversation.get('unread'),
|
||||
lastStatusId: conversation.get('last_status', null),
|
||||
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'),
|
||||
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));
|
||||
|
@ -75,7 +75,7 @@ class DirectTimeline extends React.PureComponent {
|
||||
const pinned = !!columnId;
|
||||
|
||||
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}
|
||||
|
@ -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 'mastodon/selectors';
|
||||
import Avatar from 'mastodon/components/avatar';
|
||||
import DisplayName from 'mastodon/components/display_name';
|
||||
import Permalink from 'mastodon/components/permalink';
|
||||
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
||||
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
|
||||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
||||
import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { initMuteModal } from 'mastodon/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'>{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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
171
app/javascript/mastodon/features/directory/index.js
Normal file
171
app/javascript/mastodon/features/directory/index.js
Normal file
@ -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 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodon/actions/columns';
|
||||
import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import AccountCard from './components/account_card';
|
||||
import RadioButton from 'mastodon/components/radio_button';
|
||||
import classNames from 'classnames';
|
||||
import LoadMore from 'mastodon/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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -33,6 +33,7 @@ class Blocks extends ImmutablePureComponent {
|
||||
hasMore: PropTypes.bool,
|
||||
domains: ImmutablePropTypes.orderedSet,
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
@ -44,7 +45,7 @@ class Blocks extends ImmutablePureComponent {
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, domains, shouldUpdateScroll, hasMore } = this.props;
|
||||
const { intl, domains, shouldUpdateScroll, hasMore, multiColumn } = this.props;
|
||||
|
||||
if (!domains) {
|
||||
return (
|
||||
@ -57,7 +58,7 @@ 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'
|
||||
@ -65,6 +66,7 @@ class Blocks extends ImmutablePureComponent {
|
||||
hasMore={hasMore}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{domains.map(domain =>
|
||||
<DomainContainer key={domain} domain={domain} />
|
||||
|
@ -92,8 +92,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']));
|
||||
|
@ -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) {
|
||||
|
@ -74,7 +74,7 @@ class Favourites extends ImmutablePureComponent {
|
||||
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite posts yet. When you favourite one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column ref={this.setRef} label={intl.formatMessage(messages.heading)}>
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
|
||||
<ColumnHeader
|
||||
icon='star'
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
@ -95,6 +95,7 @@ class Favourites extends ImmutablePureComponent {
|
||||
onLoadMore={this.handleLoadMore}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
|
@ -5,17 +5,23 @@ import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import { fetchFavourites } from '../../actions/interactions';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import AccountContainer from '../../containers/account_container';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnBackButton from '../../components/column_back_button';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
|
||||
const messages = defineMessages({
|
||||
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class Favourites extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
@ -23,10 +29,14 @@ class Favourites extends ImmutablePureComponent {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
shouldUpdateScroll: PropTypes.func,
|
||||
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) {
|
||||
@ -35,8 +45,12 @@ class Favourites extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleRefresh = () => {
|
||||
this.props.dispatch(fetchFavourites(this.props.params.statusId));
|
||||
}
|
||||
|
||||
render () {
|
||||
const { shouldUpdateScroll, accountIds } = this.props;
|
||||
const { intl, shouldUpdateScroll, accountIds, multiColumn } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
@ -49,13 +63,20 @@ class Favourites extends ImmutablePureComponent {
|
||||
const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has favourited this post yet. When someone does, they will show up here.' />;
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<ColumnHeader
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='favourites'
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} withNote={false} />
|
||||
|
@ -32,6 +32,7 @@ class FollowRequests extends ImmutablePureComponent {
|
||||
hasMore: PropTypes.bool,
|
||||
accountIds: ImmutablePropTypes.list,
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
@ -43,7 +44,7 @@ class FollowRequests extends ImmutablePureComponent {
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, shouldUpdateScroll, accountIds, hasMore } = this.props;
|
||||
const { intl, shouldUpdateScroll, accountIds, hasMore, multiColumn } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
@ -56,7 +57,7 @@ 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 icon='user-plus' heading={intl.formatMessage(messages.heading)}>
|
||||
<Column bindToDocument={!multiColumn} icon='user-plus' heading={intl.formatMessage(messages.heading)}>
|
||||
<ColumnBackButtonSlim />
|
||||
<ScrollableList
|
||||
scrollKey='follow_requests'
|
||||
@ -64,6 +65,7 @@ class FollowRequests extends ImmutablePureComponent {
|
||||
hasMore={hasMore}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountAuthorizeContainer key={id} id={id} />
|
||||
|
@ -36,11 +36,14 @@ class Followers extends ImmutablePureComponent {
|
||||
hasMore: PropTypes.bool,
|
||||
blockedBy: 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) {
|
||||
@ -55,7 +58,7 @@ class Followers extends ImmutablePureComponent {
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount } = this.props;
|
||||
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn } = this.props;
|
||||
|
||||
if (!isAccount) {
|
||||
return (
|
||||
@ -77,7 +80,7 @@ class Followers extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
<ColumnBackButton multiColumn={multiColumn} />
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='followers'
|
||||
@ -87,6 +90,7 @@ class Followers extends ImmutablePureComponent {
|
||||
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
|
||||
alwaysPrepend
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{blockedBy ? [] : accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} withNote={false} />
|
||||
|
@ -36,11 +36,14 @@ class Following extends ImmutablePureComponent {
|
||||
hasMore: PropTypes.bool,
|
||||
blockedBy: 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) {
|
||||
@ -55,7 +58,7 @@ class Following extends ImmutablePureComponent {
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount } = this.props;
|
||||
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn } = this.props;
|
||||
|
||||
if (!isAccount) {
|
||||
return (
|
||||
@ -77,7 +80,7 @@ class Following extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
<ColumnBackButton multiColumn={multiColumn} />
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='following'
|
||||
@ -87,6 +90,7 @@ class Following extends ImmutablePureComponent {
|
||||
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
|
||||
alwaysPrepend
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{blockedBy ? [] : accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} withNote={false} />
|
||||
|
@ -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 'mastodon/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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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);
|
@ -7,12 +7,13 @@ import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { me, profile_directory } from '../../initial_state';
|
||||
import { me, profile_directory, showTrends } from '../../initial_state';
|
||||
import { fetchFollowRequests } from 'mastodon/actions/accounts';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import NavigationBar from '../compose/components/navigation_bar';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import LinkFooter from 'mastodon/features/ui/components/link_footer';
|
||||
import TrendsContainer from './containers/trends_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
@ -76,16 +77,14 @@ class GettingStarted extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
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 () {
|
||||
@ -106,7 +105,7 @@ class GettingStarted extends ImmutablePureComponent {
|
||||
|
||||
if (profile_directory) {
|
||||
navItems.push(
|
||||
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} href='/explore' />
|
||||
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />
|
||||
);
|
||||
|
||||
height += 48;
|
||||
@ -119,7 +118,7 @@ class GettingStarted extends ImmutablePureComponent {
|
||||
height += 34;
|
||||
} else if (profile_directory) {
|
||||
navItems.push(
|
||||
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} href='/explore' />
|
||||
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />
|
||||
);
|
||||
|
||||
height += 48;
|
||||
@ -133,7 +132,7 @@ class GettingStarted extends ImmutablePureComponent {
|
||||
|
||||
height += 48*3;
|
||||
|
||||
if (myAccount.get('locked')) {
|
||||
if (myAccount.get('locked') || unreadFollowRequests > 0) {
|
||||
navItems.push(<ColumnLink key={i++} icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
|
||||
height += 48;
|
||||
}
|
||||
@ -148,7 +147,7 @@ class GettingStarted extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.menu)}>
|
||||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.menu)}>
|
||||
{multiColumn && <div className='column-header__wrapper'>
|
||||
<h1 className='column-header'>
|
||||
<button>
|
||||
@ -168,6 +167,8 @@ class GettingStarted extends ImmutablePureComponent {
|
||||
|
||||
<LinkFooter withHotkeys={multiColumn} />
|
||||
</div>
|
||||
|
||||
{multiColumn && showTrends && <TrendsContainer />}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
@ -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}` };
|
||||
});
|
||||
|
@ -135,7 +135,7 @@ class HashtagTimeline extends React.PureComponent {
|
||||
const pinned = !!columnId;
|
||||
|
||||
return (
|
||||
<Column ref={this.setRef} label={`#${id}`}>
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={`#${id}`}>
|
||||
<ColumnHeader
|
||||
icon='hashtag'
|
||||
active={hasUnread}
|
||||
@ -157,6 +157,7 @@ class HashtagTimeline extends React.PureComponent {
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
|
@ -98,7 +98,7 @@ class HomeTimeline extends React.PureComponent {
|
||||
const pinned = !!columnId;
|
||||
|
||||
return (
|
||||
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
icon='home'
|
||||
active={hasUnread}
|
||||
@ -119,6 +119,7 @@ class HomeTimeline extends React.PureComponent {
|
||||
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> }} />}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
|
@ -18,10 +18,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
const { intl, 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>
|
||||
|
@ -148,14 +148,14 @@ class ListTimeline extends React.PureComponent {
|
||||
} else if (list === false) {
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
<ColumnBackButton multiColumn={multiColumn} />
|
||||
<MissingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column ref={this.setRef} label={title}>
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={title}>
|
||||
<ColumnHeader
|
||||
icon='list-ul'
|
||||
active={hasUnread}
|
||||
@ -184,6 +184,7 @@ class ListTimeline extends React.PureComponent {
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet. When members of this list post new statuses, they will appear here.' />}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
|
@ -40,6 +40,7 @@ class Lists extends ImmutablePureComponent {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
lists: ImmutablePropTypes.list,
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
@ -47,7 +48,7 @@ class Lists extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, shouldUpdateScroll, lists } = this.props;
|
||||
const { intl, shouldUpdateScroll, lists, multiColumn } = this.props;
|
||||
|
||||
if (!lists) {
|
||||
return (
|
||||
@ -60,7 +61,7 @@ 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='list-ul' heading={intl.formatMessage(messages.heading)}>
|
||||
<Column bindToDocument={!multiColumn} icon='list-ul' heading={intl.formatMessage(messages.heading)}>
|
||||
<ColumnBackButtonSlim />
|
||||
|
||||
<NewListForm />
|
||||
@ -70,6 +71,7 @@ class Lists extends ImmutablePureComponent {
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{lists.map(list =>
|
||||
<ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
|
||||
|
@ -32,6 +32,7 @@ class Mutes extends ImmutablePureComponent {
|
||||
hasMore: PropTypes.bool,
|
||||
accountIds: ImmutablePropTypes.list,
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
@ -43,7 +44,7 @@ class Mutes extends ImmutablePureComponent {
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, shouldUpdateScroll, hasMore, accountIds } = this.props;
|
||||
const { intl, shouldUpdateScroll, hasMore, accountIds, multiColumn } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
@ -56,7 +57,7 @@ class Mutes extends ImmutablePureComponent {
|
||||
const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />;
|
||||
|
||||
return (
|
||||
<Column icon='volume-off' heading={intl.formatMessage(messages.heading)}>
|
||||
<Column bindToDocument={!multiColumn} icon='volume-off' heading={intl.formatMessage(messages.heading)}>
|
||||
<ColumnBackButtonSlim />
|
||||
<ScrollableList
|
||||
scrollKey='mutes'
|
||||
@ -64,6 +65,7 @@ class Mutes extends ImmutablePureComponent {
|
||||
hasMore={hasMore}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} />
|
||||
|
@ -64,7 +64,7 @@ class FilterBar extends React.PureComponent {
|
||||
onClick={this.onClick('mention')}
|
||||
title={intl.formatMessage(tooltips.mentions)}
|
||||
>
|
||||
<Icon id='at' fixedWidth />
|
||||
<Icon id='reply-all' fixedWidth />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'favourite' ? 'active' : ''}
|
||||
|
@ -11,6 +11,7 @@ export default class SettingToggle extends React.PureComponent {
|
||||
settingPath: PropTypes.array.isRequired,
|
||||
label: PropTypes.node.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
defaultValue: PropTypes.bool,
|
||||
}
|
||||
|
||||
onChange = ({ target }) => {
|
||||
@ -18,12 +19,12 @@ export default class SettingToggle extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { prefix, settings, settingPath, label } = this.props;
|
||||
const { prefix, settings, settingPath, label, 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>
|
||||
</div>
|
||||
);
|
||||
|
@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Column from '../../components/column';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { expandNotifications, scrollTopNotifications } from '../../actions/notifications';
|
||||
import { expandNotifications, scrollTopNotifications, loadPending, mountNotifications, unmountNotifications } from '../../actions/notifications';
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import NotificationContainer from './containers/notification_container';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
@ -39,8 +39,9 @@ const mapStateToProps = state => ({
|
||||
showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
|
||||
notifications: getNotifications(state),
|
||||
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,
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@ -58,17 +59,23 @@ class Notifications extends React.PureComponent {
|
||||
isUnread: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
numPending: PropTypes.number,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
trackScroll: true,
|
||||
};
|
||||
|
||||
componentWillMount() {
|
||||
this.props.dispatch(mountNotifications());
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.handleLoadOlder.cancel();
|
||||
this.handleScrollToTop.cancel();
|
||||
this.handleScroll.cancel();
|
||||
this.props.dispatch(scrollTopNotifications(false));
|
||||
this.props.dispatch(unmountNotifications());
|
||||
}
|
||||
|
||||
handleLoadGap = (maxId) => {
|
||||
@ -80,6 +87,10 @@ 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);
|
||||
@ -136,7 +147,7 @@ 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." />;
|
||||
|
||||
@ -178,18 +189,21 @@ 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>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column ref={this.setColumnRef} label={intl.formatMessage(messages.title)}>
|
||||
<Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
icon='bell'
|
||||
active={isUnread}
|
||||
|
@ -28,6 +28,7 @@ class PinnedStatuses extends ImmutablePureComponent {
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hasMore: PropTypes.bool.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
@ -43,16 +44,17 @@ class PinnedStatuses extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, shouldUpdateScroll, statusIds, hasMore } = this.props;
|
||||
const { intl, shouldUpdateScroll, 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}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
|
@ -105,7 +105,7 @@ class PublicTimeline extends React.PureComponent {
|
||||
const pinned = !!columnId;
|
||||
|
||||
return (
|
||||
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
icon='globe'
|
||||
active={hasUnread}
|
||||
@ -126,6 +126,7 @@ class PublicTimeline extends React.PureComponent {
|
||||
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' />}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
|
@ -5,17 +5,23 @@ import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import { fetchReblogs } from '../../actions/interactions';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import AccountContainer from '../../containers/account_container';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnBackButton from '../../components/column_back_button';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
|
||||
const messages = defineMessages({
|
||||
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class Reblogs extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
@ -23,10 +29,14 @@ class Reblogs extends ImmutablePureComponent {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
shouldUpdateScroll: PropTypes.func,
|
||||
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) {
|
||||
@ -35,8 +45,12 @@ class Reblogs extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleRefresh = () => {
|
||||
this.props.dispatch(fetchReblogs(this.props.params.statusId));
|
||||
}
|
||||
|
||||
render () {
|
||||
const { shouldUpdateScroll, accountIds } = this.props;
|
||||
const { intl, shouldUpdateScroll, accountIds, multiColumn } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
@ -49,13 +63,20 @@ class Reblogs extends ImmutablePureComponent {
|
||||
const emptyMessage = <FormattedMessage id='status.reblogs.empty' defaultMessage='No one has boosted this post yet. When someone does, they will show up here.' />;
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<ColumnHeader
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='reblogs'
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} withNote={false} />
|
||||
|
@ -10,6 +10,7 @@ import { defineMessages, injectIntl, FormattedDate, FormattedNumber } from 'reac
|
||||
import Card from './card';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Video from '../../video';
|
||||
import Audio from '../../audio';
|
||||
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
@ -114,7 +115,19 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
if (status.get('media_attachments').size > 0) {
|
||||
if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
|
||||
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
|
||||
/>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import DetailedStatus from '../components/detailed_status';
|
||||
import { makeGetStatus } from '../../../selectors';
|
||||
@ -15,7 +14,6 @@ import {
|
||||
pin,
|
||||
unpin,
|
||||
} from '../../../actions/interactions';
|
||||
import { blockAccount } from '../../../actions/accounts';
|
||||
import {
|
||||
muteStatus,
|
||||
unmuteStatus,
|
||||
@ -24,9 +22,10 @@ import {
|
||||
revealStatus,
|
||||
} from '../../../actions/statuses';
|
||||
import { initMuteModal } from '../../../actions/mutes';
|
||||
import { initBlockModal } from '../../../actions/blocks';
|
||||
import { initReport } from '../../../actions/reports';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { boostModal, deleteModal } from '../../../initial_state';
|
||||
import { showAlertForError } from '../../../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 = () => {
|
||||
@ -138,16 +135,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) {
|
||||
|
@ -23,7 +23,6 @@ import {
|
||||
mentionCompose,
|
||||
directCompose,
|
||||
} from '../../actions/compose';
|
||||
import { blockAccount } from '../../actions/accounts';
|
||||
import {
|
||||
muteStatus,
|
||||
unmuteStatus,
|
||||
@ -32,6 +31,7 @@ import {
|
||||
revealStatus,
|
||||
} from '../../actions/statuses';
|
||||
import { initMuteModal } from '../../actions/mutes';
|
||||
import { initBlockModal } from '../../actions/blocks';
|
||||
import { initReport } from '../../actions/reports';
|
||||
import { makeGetStatus } from '../../selectors';
|
||||
import { ScrollContainer } from 'react-router-scroll-4';
|
||||
@ -39,7 +39,7 @@ import ColumnBackButton from '../../components/column_back_button';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import StatusContainer from '../../containers/status_container';
|
||||
import { openModal } from '../../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, deleteModal } from '../../initial_state';
|
||||
@ -52,13 +52,11 @@ 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' },
|
||||
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' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
@ -84,28 +82,38 @@ const makeMapStateToProps = () => {
|
||||
const getDescendantsIds = createSelector([
|
||||
(_, { id }) => id,
|
||||
state => state.getIn(['contexts', 'replies']),
|
||||
], (statusId, contextReplies) => {
|
||||
let descendantsIds = Immutable.List();
|
||||
descendantsIds = descendantsIds.withMutations(mutable => {
|
||||
const ids = [statusId];
|
||||
state => state.get('statuses'),
|
||||
], (statusId, contextReplies, statuses) => {
|
||||
let descendantsIds = [];
|
||||
const ids = [statusId];
|
||||
|
||||
while (ids.length > 0) {
|
||||
let id = ids.shift();
|
||||
const replies = contextReplies.get(id);
|
||||
while (ids.length > 0) {
|
||||
let id = ids.shift();
|
||||
const replies = contextReplies.get(id);
|
||||
|
||||
if (statusId !== id) {
|
||||
mutable.push(id);
|
||||
}
|
||||
|
||||
if (replies) {
|
||||
replies.reverse().forEach(reply => {
|
||||
ids.unshift(reply);
|
||||
});
|
||||
}
|
||||
if (statusId !== id) {
|
||||
descendantsIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
return descendantsIds;
|
||||
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) => {
|
||||
@ -146,6 +154,7 @@ class Status extends ImmutablePureComponent {
|
||||
descendantsIds: ImmutablePropTypes.list,
|
||||
intl: PropTypes.object.isRequired,
|
||||
askReplyConfirmation: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
domain: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
@ -285,19 +294,9 @@ 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) => {
|
||||
@ -437,13 +436,13 @@ class Status extends ImmutablePureComponent {
|
||||
|
||||
render () {
|
||||
let ancestors, descendants;
|
||||
const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain } = this.props;
|
||||
const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
|
||||
if (status === null) {
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
<ColumnBackButton multiColumn={multiColumn} />
|
||||
<MissingIndicator />
|
||||
</Column>
|
||||
);
|
||||
@ -470,9 +469,10 @@ class Status extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.detailedStatus)}>
|
||||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.detailedStatus)}>
|
||||
<ColumnHeader
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
extraButton={(
|
||||
<button className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={status.get('hidden') ? 'false' : 'true'}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} /></button>
|
||||
)}
|
||||
|
@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { previewState } from './video_modal';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/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,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
if (this.context.router) {
|
||||
const history = this.context.router.history;
|
||||
|
||||
history.push(history.location.pathname, previewState);
|
||||
|
||||
this.unlistenHistory = history.listen(() => {
|
||||
this.props.onClose();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.context.router) {
|
||||
this.unlistenHistory();
|
||||
|
||||
if (this.context.router.history.location.state === previewState) {
|
||||
this.context.router.history.goBack();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
103
app/javascript/mastodon/features/ui/components/block_modal.js
Normal file
103
app/javascript/mastodon/features/ui/components/block_modal.js
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -12,7 +12,18 @@ 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, ListTimeline } from '../../ui/util/async-components';
|
||||
import {
|
||||
Compose,
|
||||
Notifications,
|
||||
HomeTimeline,
|
||||
CommunityTimeline,
|
||||
PublicTimeline,
|
||||
HashtagTimeline,
|
||||
DirectTimeline,
|
||||
FavouritedStatuses,
|
||||
ListTimeline,
|
||||
Directory,
|
||||
} from '../../ui/util/async-components';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import ComposePanel from './compose_panel';
|
||||
import NavigationPanel from './navigation_panel';
|
||||
@ -30,6 +41,7 @@ const componentMap = {
|
||||
'DIRECT': DirectTimeline,
|
||||
'FAVOURITES': FavouritedStatuses,
|
||||
'LIST': ListTimeline,
|
||||
'DIRECTORY': Directory,
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -0,0 +1,41 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { title } from 'mastodon/initial_state';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
unread: state.getIn(['missed_updates', 'unread']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
class DocumentTitle extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
unread: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
this._sideEffects();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this._sideEffects();
|
||||
}
|
||||
|
||||
_sideEffects () {
|
||||
const { unread } = this.props;
|
||||
|
||||
if (unread > 99) {
|
||||
document.title = `(*) ${title}`;
|
||||
} else if (unread > 0) {
|
||||
document.title = `(${unread}) ${title}`;
|
||||
} else {
|
||||
document.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
@ -1,8 +1,13 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
import api from '../../../api';
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
import api from 'mastodon/api';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class EmbedModal extends ImmutablePureComponent {
|
||||
@ -50,13 +55,17 @@ 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>
|
||||
|
@ -1,11 +1,27 @@
|
||||
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 '../../../actions/compose';
|
||||
import { getPointerPosition } from '../../video';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import Button from 'mastodon/components/button';
|
||||
import Video from 'mastodon/features/video';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
import UploadProgress from 'mastodon/features/compose/components/upload_progress';
|
||||
import CharacterCounter from 'mastodon/features/compose/components/character_counter';
|
||||
import { length } from 'stringz';
|
||||
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
||||
|
||||
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 +29,26 @@ 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)}` }));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
|
||||
.replace(/\n/g, ' ')
|
||||
.replace(/\*\*\*\*\*\*/g, '\n\n');
|
||||
|
||||
const assetHost = process.env.CDN_HOST || '';
|
||||
|
||||
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 +57,9 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||
focusX: 0,
|
||||
focusY: 0,
|
||||
dragging: false,
|
||||
description: '',
|
||||
dirty: false,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
@ -57,6 +85,14 @@ 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 +102,13 @@ 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 +116,166 @@ 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 });
|
||||
}
|
||||
|
||||
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('file');
|
||||
|
||||
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;
|
||||
|
||||
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='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='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
|
||||
<div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
|
||||
<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'><FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' /></label>
|
||||
|
||||
<div className='setting-text__wrapper'>
|
||||
<Textarea
|
||||
id='upload-modal__description'
|
||||
className='setting-text light'
|
||||
value={detecting ? '…' : description}
|
||||
onChange={this.handleChange}
|
||||
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' && <img src={media.get('url')} width={width} height={height} alt='' />}
|
||||
{media.get('type') === 'gifv' && <video src={media.get('url')} width={width} height={height} loop muted autoPlay />}
|
||||
|
||||
<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>
|
||||
);
|
||||
|
@ -1,39 +1,77 @@
|
||||
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 'mastodon/initial_state';
|
||||
import { logOut } from 'mastodon/utils/log_out';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
|
||||
const LinkFooter = ({ withHotkeys }) => (
|
||||
<div className='getting-started__footer'>
|
||||
<ul>
|
||||
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
|
||||
{withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </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='/auth/sign_out' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
|
||||
</ul>
|
||||
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' },
|
||||
});
|
||||
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='getting_started.open_source_notice'
|
||||
defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.'
|
||||
values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }}
|
||||
/> <FormattedMessage
|
||||
id='getting_started.hometown_open_source_notice'
|
||||
defaultMessage='Hometown is also open source, at {hometown} (v1.0.2).'
|
||||
values={{ hometown: <span><a href='https://github.com/hometown-fork/hometown' rel='noopener' target='_blank'>hometown-fork/hometown</a></span> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
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 = {
|
||||
withHotkeys: PropTypes.bool,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleLogoutClick = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.props.onLogout();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { withHotkeys } = this.props;
|
||||
|
||||
return (
|
||||
<div className='getting-started__footer'>
|
||||
<ul>
|
||||
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
|
||||
{withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </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='/auth/sign_out' onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='getting_started.open_source_notice'
|
||||
defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.'
|
||||
values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='getting_started.hometown_open_source_notice'
|
||||
defaultMessage='Hometown is also open source, at {hometown} (v1.0.3).'
|
||||
values={{ hometown: <span><a href='https://github.com/hometown-fork/hometown' rel='noopener' target='_blank'>hometown-fork/hometown</a></span> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LinkFooter.propTypes = {
|
||||
withHotkeys: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default LinkFooter;
|
||||
|
@ -228,7 +228,7 @@ 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>
|
||||
)}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Base from '../../../components/modal_root';
|
||||
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
||||
import Base from 'mastodon/components/modal_root';
|
||||
import BundleContainer from '../containers/bundle_container';
|
||||
import BundleModalError from './bundle_modal_error';
|
||||
import ModalLoading from './modal_loading';
|
||||
@ -8,10 +9,12 @@ import ActionsModal from './actions_modal';
|
||||
import MediaModal from './media_modal';
|
||||
import VideoModal from './video_modal';
|
||||
import BoostModal from './boost_modal';
|
||||
import AudioModal from './audio_modal';
|
||||
import ConfirmationModal from './confirmation_modal';
|
||||
import FocalPointModal from './focal_point_modal';
|
||||
import {
|
||||
MuteModal,
|
||||
BlockModal,
|
||||
ReportModal,
|
||||
EmbedModal,
|
||||
ListEditor,
|
||||
@ -21,9 +24,11 @@ import {
|
||||
const MODAL_COMPONENTS = {
|
||||
'MEDIA': () => Promise.resolve({ default: MediaModal }),
|
||||
'VIDEO': () => Promise.resolve({ default: VideoModal }),
|
||||
'AUDIO': () => Promise.resolve({ default: AudioModal }),
|
||||
'BOOST': () => Promise.resolve({ default: BoostModal }),
|
||||
'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
|
||||
'MUTE': MuteModal,
|
||||
'BLOCK': BlockModal,
|
||||
'REPORT': ReportModal,
|
||||
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
||||
'EMBED': EmbedModal,
|
||||
@ -47,8 +52,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,6 @@ import { toggleHideNotifications } from '../../../actions/mutes';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
|
||||
account: state.getIn(['mutes', 'new', 'account']),
|
||||
notifications: state.getIn(['mutes', 'new', 'notifications']),
|
||||
};
|
||||
@ -38,7 +37,6 @@ export default @connect(mapStateToProps, mapDispatchToProps)
|
||||
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 @@ 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>
|
||||
|
@ -2,10 +2,11 @@ import React from 'react';
|
||||
import { NavLink, withRouter } from 'react-router-dom';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { profile_directory } from 'mastodon/initial_state';
|
||||
import { profile_directory, showTrends } from 'mastodon/initial_state';
|
||||
import NotificationsCounterIcon from './notifications_counter_icon';
|
||||
import FollowRequestsNavLink from './follow_requests_nav_link';
|
||||
import ListPanel from './list_panel';
|
||||
import TrendsContainer from 'mastodon/features/getting_started/containers/trends_container';
|
||||
|
||||
const NavigationPanel = () => (
|
||||
<div className='navigation-panel'>
|
||||
@ -17,6 +18,7 @@ const NavigationPanel = () => (
|
||||
<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='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></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>
|
||||
{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>}
|
||||
|
||||
<ListPanel />
|
||||
|
||||
@ -24,7 +26,9 @@ const NavigationPanel = () => (
|
||||
|
||||
<a className='column-link column-link--transparent' href='/settings/preferences'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>
|
||||
<a className='column-link column-link--transparent' href='/relationships'><Icon className='column-link__icon' id='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>}
|
||||
|
||||
{showTrends && <div className='flex-spacer' />}
|
||||
{showTrends && <TrendsContainer />}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -73,9 +73,13 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,8 @@ import PropTypes from 'prop-types';
|
||||
import Video from 'mastodon/features/video';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
||||
export const previewState = 'previewVideoModal';
|
||||
|
||||
@ -52,22 +54,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>
|
||||
);
|
||||
}
|
||||
|
@ -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`]);
|
||||
}
|
||||
}));
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user