Merge tag 'v4.2.12' into chinwag-next

This commit is contained in:
Mike Barnes 2024-09-24 20:03:33 +10:00
commit 9bcb7630b3
3138 changed files with 94619 additions and 59187 deletions

View file

@ -1,37 +0,0 @@
import api from '../api';
export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
export function submitAccountNote(id, value) {
return (dispatch, getState) => {
dispatch(submitAccountNoteRequest());
api(getState).post(`/api/v1/accounts/${id}/note`, {
comment: value,
}).then(response => {
dispatch(submitAccountNoteSuccess(response.data));
}).catch(error => dispatch(submitAccountNoteFail(error)));
};
}
export function submitAccountNoteRequest() {
return {
type: ACCOUNT_NOTE_SUBMIT_REQUEST,
};
}
export function submitAccountNoteSuccess(relationship) {
return {
type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
relationship,
};
}
export function submitAccountNoteFail(error) {
return {
type: ACCOUNT_NOTE_SUBMIT_FAIL,
error,
};
}

View file

@ -0,0 +1,18 @@
import { createAppAsyncThunk } from 'mastodon/store/typed_functions';
import api from '../api';
export const submitAccountNote = createAppAsyncThunk(
'account_note/submit',
async (args: { id: string; value: string }, { getState }) => {
// TODO: replace `unknown` with `ApiRelationshipJSON` when it is merged
const response = await api(getState).post<unknown>(
`/api/v1/accounts/${args.id}/note`,
{
comment: args.value,
},
);
return { relationship: response.data };
},
);

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import { importFetchedAccount, importFetchedAccounts } from './importer';
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';

View file

@ -12,52 +12,48 @@ export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR';
export const ALERT_NOOP = 'ALERT_NOOP';
export function dismissAlert(alert) {
return {
type: ALERT_DISMISS,
alert,
};
}
export const dismissAlert = alert => ({
type: ALERT_DISMISS,
alert,
});
export function clearAlert() {
return {
type: ALERT_CLEAR,
};
}
export const clearAlert = () => ({
type: ALERT_CLEAR,
});
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
return {
type: ALERT_SHOW,
title,
message,
message_values,
};
}
export const showAlert = alert => ({
type: ALERT_SHOW,
alert,
});
export function showAlertForError(error, skipNotFound = false) {
export const showAlertForError = (error, skipNotFound = false) => {
if (error.response) {
const { data, status, statusText, headers } = error.response;
// Skip these errors as they are reflected in the UI
if (skipNotFound && (status === 404 || status === 410)) {
// Skip these errors as they are reflected in the UI
return { type: ALERT_NOOP };
}
// Rate limit errors
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 });
return showAlert({
title: messages.rateLimitedTitle,
message: messages.rateLimitedMessage,
values: { 'retry_time': new Date(headers['x-ratelimit-reset']) },
});
}
let message = statusText;
let title = `${status}`;
if (data.error) {
message = data.error;
}
return showAlert(title, message);
} else {
console.error(error);
return showAlert();
return showAlert({
title: `${status}`,
message: data.error || statusText,
});
}
console.error(error);
return showAlert({
title: messages.unexpectedTitle,
message: messages.unexpectedMessage,
});
}

View file

@ -1,4 +1,5 @@
import api from '../api';
import { normalizeAnnouncement } from './importer/normalizer';
export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST';

View file

@ -1,17 +0,0 @@
export const APP_FOCUS = 'APP_FOCUS';
export const APP_UNFOCUS = 'APP_UNFOCUS';
export const focusApp = () => ({
type: APP_FOCUS,
});
export const unfocusApp = () => ({
type: APP_UNFOCUS,
});
export const APP_LAYOUT_CHANGE = 'APP_LAYOUT_CHANGE';
export const changeLayout = layout => ({
type: APP_LAYOUT_CHANGE,
layout,
});

View file

@ -0,0 +1,12 @@
import { createAction } from '@reduxjs/toolkit';
import type { LayoutType } from '../is_mobile';
export const focusApp = createAction('APP_FOCUS');
export const unfocusApp = createAction('APP_UNFOCUS');
interface ChangeLayoutPayload {
layout: LayoutType;
}
export const changeLayout =
createAction<ChangeLayoutPayload>('APP_LAYOUT_CHANGE');

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
import { openModal } from './modal';
@ -94,6 +95,6 @@ export function initBlockModal(account) {
account,
});
dispatch(openModal('BLOCK'));
dispatch(openModal({ modalType: 'BLOCK' }));
};
}

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST';

View file

@ -14,7 +14,10 @@ export function initBoostModal(props) {
privacy,
});
dispatch(openModal('BOOST', props));
dispatch(openModal({
modalType: 'BOOST',
modalProps: props,
}));
};
}

View file

@ -1,10 +1,12 @@
import { defineMessages } from 'react-intl';
import axios from 'axios';
import { throttle } from 'lodash';
import { defineMessages } from 'react-intl';
import api from 'mastodon/api';
import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
import { tagHistory } from 'mastodon/settings';
import resizeImage from 'mastodon/utils/resize_image';
import { showAlert, showAlertForError } from './alerts';
import { useEmoji } from './emojis';
import { importFetchedAccounts, importFetchedStatus } from './importer';
@ -75,10 +77,14 @@ export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTIO
export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS';
export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
});
export const ensureComposeIsVisible = (getState, routerHistory) => {
@ -126,6 +132,15 @@ export function resetCompose() {
};
}
export const focusCompose = (routerHistory, defaultText) => (dispatch, getState) => {
dispatch({
type: COMPOSE_FOCUS,
defaultText,
});
ensureComposeIsVisible(getState, routerHistory);
};
export function mentionCompose(account, routerHistory) {
return (dispatch, getState) => {
dispatch({
@ -228,6 +243,13 @@ export function submitCompose(routerHistory) {
insertIfOnline('public');
insertIfOnline(`account:${response.data.account.id}`);
}
dispatch(showAlert({
message: statusId === null ? messages.published : messages.saved,
action: messages.open,
dismissAfter: 10000,
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),
}));
}).catch(function (error) {
dispatch(submitComposeFail(error));
});
@ -257,63 +279,60 @@ export function submitComposeFail(error) {
export function uploadCompose(files) {
return function (dispatch, getState) {
const uploadLimit = 4;
const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']);
const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']);
const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
if (files.length + media.size + pending > uploadLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit));
dispatch(showAlert({ message: messages.uploadErrorLimit }));
return;
}
if (getState().getIn(['compose', 'poll'])) {
dispatch(showAlert(undefined, messages.uploadErrorPoll));
dispatch(showAlert({ message: messages.uploadErrorPoll }));
return;
}
dispatch(uploadComposeRequest());
for (const [i, f] of Array.from(files).entries()) {
for (const [i, file] of Array.from(files).entries()) {
if (media.size + i > 3) break;
resizeImage(f).then(file => {
const data = new FormData();
data.append('file', file);
// Account for disparity in size of original image and resized data
total += file.size - f.size;
const data = new FormData();
data.append('file', file);
return api(getState).post('/api/v2/media', data, {
onUploadProgress: function({ loaded }){
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
},
}).then(({ status, data }) => {
// If server-side processing of the media attachment has not completed yet,
// poll the server until it is, before showing the media attachment as uploaded
api(getState).post('/api/v2/media', data, {
onUploadProgress: function({ loaded }){
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
},
}).then(({ status, data }) => {
// If server-side processing of the media attachment has not completed yet,
// poll the server until it is, before showing the media attachment as uploaded
if (status === 200) {
dispatch(uploadComposeSuccess(data, f));
} else if (status === 202) {
dispatch(uploadComposeProcessing());
if (status === 200) {
dispatch(uploadComposeSuccess(data, file));
} else if (status === 202) {
dispatch(uploadComposeProcessing());
let tryCount = 1;
let tryCount = 1;
const poll = () => {
api(getState).get(`/api/v1/media/${data.id}`).then(response => {
if (response.status === 200) {
dispatch(uploadComposeSuccess(response.data, f));
} else if (response.status === 206) {
const retryAfter = (Math.log2(tryCount) || 1) * 1000;
tryCount += 1;
setTimeout(() => poll(), retryAfter);
}
}).catch(error => dispatch(uploadComposeFail(error)));
};
const poll = () => {
api(getState).get(`/api/v1/media/${data.id}`).then(response => {
if (response.status === 200) {
dispatch(uploadComposeSuccess(response.data, file));
} else if (response.status === 206) {
const retryAfter = (Math.log2(tryCount) || 1) * 1000;
tryCount += 1;
setTimeout(() => poll(), retryAfter);
}
}).catch(error => dispatch(uploadComposeFail(error)));
};
poll();
}
});
poll();
}
}).catch(error => dispatch(uploadComposeFail(error)));
}
};
@ -373,7 +392,10 @@ export function initMediaEditModal(id) {
id,
});
dispatch(openModal('FOCAL_POINT', { id }));
dispatch(openModal({
modalType: 'FOCAL_POINT',
modalProps: { id },
}));
};
}
@ -401,16 +423,12 @@ export function changeUploadCompose(id, params) {
// Editing already-attached media is deferred to editing the post itself.
// For simplicity's sake, fake an API reply.
if (media && !media.get('unattached')) {
let { description, focus } = params;
const data = media.toJS();
if (description) {
data.description = description;
}
const { focus, ...other } = params;
const data = { ...media.toJS(), ...other };
if (focus) {
focus = focus.split(',');
data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } };
const [x, y] = focus.split(',');
data.meta = { focus: { x: parseFloat(x), y: parseFloat(y) } };
}
dispatch(changeUploadComposeSuccess(data, true));

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import {
importFetchedAccounts,
importFetchedStatuses,

View file

@ -1,6 +1,7 @@
import api from '../api';
import { importFetchedAccounts } from './importer';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';

View file

@ -1,4 +1,5 @@
import api from '../api';
import { openModal } from './modal';
export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
@ -14,9 +15,12 @@ export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
export const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL';
export const initAddFilter = (status, { contextType }) => dispatch =>
dispatch(openModal('FILTER', {
statusId: status?.get('id'),
contextType: contextType,
dispatch(openModal({
modalType: 'FILTER',
modalProps: {
statusId: status?.get('id'),
contextType: contextType,
},
}));
export const fetchFilters = () => (dispatch, getState) => {

View file

@ -1,4 +1,5 @@
import api from '../api';
import { importFetchedAccounts } from './importer';
export const HISTORY_FETCH_REQUEST = 'HISTORY_FETCH_REQUEST';

View file

@ -81,7 +81,7 @@ export function importFetchedStatuses(statuses) {
}
if (status.poll && status.poll.id) {
pushUnique(polls, normalizePoll(status.poll));
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
}
}
@ -95,7 +95,7 @@ export function importFetchedStatuses(statuses) {
}
export function importFetchedPoll(poll) {
return dispatch => {
dispatch(importPolls([normalizePoll(poll)]));
return (dispatch, getState) => {
dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))]));
};
}

View file

@ -1,11 +1,12 @@
import escapeTextContentForBrowser from 'escape-html';
import emojify from '../../features/emoji/emoji';
import { unescapeHTML } from '../../utils/html';
import { expandSpoilers } from '../../initial_state';
import { unescapeHTML } from '../../utils/html';
const domParser = new DOMParser();
const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => {
obj[`:${emoji.shortcode}:`] = emoji;
return obj;
}, {});
@ -19,7 +20,7 @@ export function searchTextFromRawStatus (status) {
export function normalizeAccount(account) {
account = { ...account };
const emojiMap = makeEmojiMap(account);
const emojiMap = makeEmojiMap(account.emojis);
const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name;
account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
@ -75,6 +76,10 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
normalStatus.spoiler_text = normalOldStatus.get('spoiler_text');
normalStatus.hidden = normalOldStatus.get('hidden');
if (normalOldStatus.get('translation')) {
normalStatus.translation = normalOldStatus.get('translation');
}
} else {
// If the status has a CW but no contents, treat the CW as if it were the
// status' contents, to avoid having a CW toggle with seemingly no effect.
@ -85,7 +90,7 @@ export function normalizeStatus(status, normalOldStatus) {
const spoilerText = normalStatus.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const emojiMap = makeEmojiMap(normalStatus);
const emojiMap = makeEmojiMap(normalStatus.emojis);
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
@ -93,25 +98,71 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
}
if (normalOldStatus) {
const list = normalOldStatus.get('media_attachments');
if (normalStatus.media_attachments && list) {
normalStatus.media_attachments.forEach(item => {
const oldItem = list.find(i => i.get('id') === item.id);
if (oldItem && oldItem.get('description') === item.description) {
item.translation = oldItem.get('translation')
}
});
}
}
return normalStatus;
}
export function normalizePoll(poll) {
const normalPoll = { ...poll };
const emojiMap = makeEmojiMap(normalPoll);
export function normalizeStatusTranslation(translation, status) {
const emojiMap = makeEmojiMap(status.get('emojis').toJS());
normalPoll.options = poll.options.map((option, index) => ({
...option,
voted: poll.own_votes && poll.own_votes.includes(index),
title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap),
}));
const normalTranslation = {
detected_source_language: translation.detected_source_language,
language: translation.language,
provider: translation.provider,
contentHtml: emojify(translation.content, emojiMap),
spoilerHtml: emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap),
spoiler_text: translation.spoiler_text,
};
return normalTranslation;
}
export function normalizePoll(poll, normalOldPoll) {
const normalPoll = { ...poll };
const emojiMap = makeEmojiMap(poll.emojis);
normalPoll.options = poll.options.map((option, index) => {
const normalOption = {
...option,
voted: poll.own_votes && poll.own_votes.includes(index),
titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap),
}
if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) {
normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']);
}
return normalOption
});
return normalPoll;
}
export function normalizePollOptionTranslation(translation, poll) {
const emojiMap = makeEmojiMap(poll.get('emojis').toJS());
const normalTranslation = {
...translation,
titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap),
};
return normalTranslation;
}
export function normalizeAnnouncement(announcement) {
const normalAnnouncement = { ...announcement };
const emojiMap = makeEmojiMap(normalAnnouncement);
const emojiMap = makeEmojiMap(normalAnnouncement.emojis);
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);

View file

@ -1,10 +1,16 @@
import api from '../api';
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatus } from './importer';
export const REBLOG_REQUEST = 'REBLOG_REQUEST';
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
export const REBLOG_FAIL = 'REBLOG_FAIL';
export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
export const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL';
export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
@ -25,6 +31,10 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
export const FAVOURITES_EXPAND_REQUEST = 'FAVOURITES_EXPAND_REQUEST';
export const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS';
export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL';
export const PIN_REQUEST = 'PIN_REQUEST';
export const PIN_SUCCESS = 'PIN_SUCCESS';
export const PIN_FAIL = 'PIN_FAIL';
@ -272,8 +282,10 @@ export function fetchReblogs(id) {
dispatch(fetchReblogsRequest(id));
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(fetchReblogsSuccess(id, response.data));
dispatch(fetchReblogsSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(fetchReblogsFail(id, error));
});
@ -287,17 +299,62 @@ export function fetchReblogsRequest(id) {
};
}
export function fetchReblogsSuccess(id, accounts) {
export function fetchReblogsSuccess(id, accounts, next) {
return {
type: REBLOGS_FETCH_SUCCESS,
id,
accounts,
next,
};
}
export function fetchReblogsFail(id, error) {
return {
type: REBLOGS_FETCH_FAIL,
id,
error,
};
}
export function expandReblogs(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'reblogged_by', id, 'next']);
if (url === null) {
return;
}
dispatch(expandReblogsRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(expandReblogsSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => dispatch(expandReblogsFail(id, error)));
};
}
export function expandReblogsRequest(id) {
return {
type: REBLOGS_EXPAND_REQUEST,
id,
};
}
export function expandReblogsSuccess(id, accounts, next) {
return {
type: REBLOGS_EXPAND_SUCCESS,
id,
accounts,
next,
};
}
export function expandReblogsFail(id, error) {
return {
type: REBLOGS_EXPAND_FAIL,
id,
error,
};
}
@ -307,8 +364,10 @@ export function fetchFavourites(id) {
dispatch(fetchFavouritesRequest(id));
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(fetchFavouritesSuccess(id, response.data));
dispatch(fetchFavouritesSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(fetchFavouritesFail(id, error));
});
@ -322,17 +381,62 @@ export function fetchFavouritesRequest(id) {
};
}
export function fetchFavouritesSuccess(id, accounts) {
export function fetchFavouritesSuccess(id, accounts, next) {
return {
type: FAVOURITES_FETCH_SUCCESS,
id,
accounts,
next,
};
}
export function fetchFavouritesFail(id, error) {
return {
type: FAVOURITES_FETCH_FAIL,
id,
error,
};
}
export function expandFavourites(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'favourited_by', id, 'next']);
if (url === null) {
return;
}
dispatch(expandFavouritesRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(expandFavouritesSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => dispatch(expandFavouritesFail(id, error)));
};
}
export function expandFavouritesRequest(id) {
return {
type: FAVOURITES_EXPAND_REQUEST,
id,
};
}
export function expandFavouritesSuccess(id, accounts, next) {
return {
type: FAVOURITES_EXPAND_SUCCESS,
id,
accounts,
next,
};
}
export function expandFavouritesFail(id, error) {
return {
type: FAVOURITES_EXPAND_FAIL,
id,
error,
};
}

View file

@ -1,6 +1,7 @@
import api from '../api';
import { importFetchedAccounts } from './importer';
import { showAlertForError } from './alerts';
import { importFetchedAccounts } from './importer';
export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
@ -150,10 +151,10 @@ export const createListFail = error => ({
error,
});
export const updateList = (id, title, shouldReset, replies_policy) => (dispatch, getState) => {
export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => {
dispatch(updateListRequest(id));
api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy }).then(({ data }) => {
api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => {
dispatch(updateListSuccess(data));
if (shouldReset) {

View file

@ -1,8 +1,10 @@
import api from '../api';
import { debounce } from 'lodash';
import compareId from '../compare_id';
import { List as ImmutableList } from 'immutable';
import { debounce } from 'lodash';
import api from '../api';
import { compareId } from '../compare_id';
export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST';
export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS';
export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL';
@ -55,7 +57,7 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
client.open('POST', '/api/v1/markers', false);
client.setRequestHeader('Content-Type', 'application/json');
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
client.SUBMIT(JSON.stringify(params));
client.send(JSON.stringify(params));
} catch (e) {
// Do not make the BeforeUnload handler error out
}

View file

@ -1,18 +0,0 @@
export const MODAL_OPEN = 'MODAL_OPEN';
export const MODAL_CLOSE = 'MODAL_CLOSE';
export function openModal(type, props) {
return {
type: MODAL_OPEN,
modalType: type,
modalProps: props,
};
}
export function closeModal(type, options = { ignoreFocus: false }) {
return {
type: MODAL_CLOSE,
modalType: type,
ignoreFocus: options.ignoreFocus,
};
}

View file

@ -0,0 +1,17 @@
import { createAction } from '@reduxjs/toolkit';
import type { MODAL_COMPONENTS } from '../features/ui/components/modal_root';
export type ModalType = keyof typeof MODAL_COMPONENTS;
interface OpenModalPayload {
modalType: ModalType;
modalProps: unknown;
}
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
interface CloseModalPayload {
modalType: ModalType | undefined;
ignoreFocus: boolean;
}
export const closeModal = createAction<CloseModalPayload>('MODAL_CLOSE');

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
import { openModal } from './modal';
@ -96,7 +97,7 @@ export function initMuteModal(account) {
account,
});
dispatch(openModal('MUTE'));
dispatch(openModal({ modalType: 'MUTE' }));
};
}

View file

@ -1,5 +1,15 @@
import { IntlMessageFormat } from 'intl-messageformat';
import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { compareId } from 'mastodon/compare_id';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import api, { getLinks } from '../api';
import IntlMessageFormat from 'intl-messageformat';
import { unescapeHTML } from '../utils/html';
import { requestNotificationPermission } from '../utils/notifications';
import { fetchFollowRequests, fetchRelationships } from './accounts';
import {
importFetchedAccount,
@ -8,13 +18,8 @@ import {
importFetchedStatuses,
} from './importer';
import { submitMarkers } from './markers';
import { register as registerPushNotifications } from './push_notifications';
import { saveSettings } from './settings';
import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from '../utils/html';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import compareId from 'mastodon/compare_id';
import { requestNotificationPermission } from '../utils/notifications';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@ -289,6 +294,10 @@ export function requestBrowserPermission(callback = noOp) {
requestNotificationPermission((permission) => {
dispatch(setBrowserPermission(permission));
callback(permission);
if (permission === 'granted') {
dispatch(registerPushNotifications());
}
});
};
}

View file

@ -20,9 +20,10 @@ export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE';
* @param {string} accountId
* @param {string} playerType
* @param {MediaProps} props
* @return {object}
* @returns {object}
*/
export const deployPictureInPicture = (statusId, accountId, playerType, props) => {
// @ts-expect-error
return (dispatch, getState) => {
// Do not open a player for a toot that does not exist
if (getState().hasIn(['statuses', statusId])) {

View file

@ -1,12 +1,12 @@
import api from '../api';
import { me } from '../initial_state';
import { importFetchedStatuses } from './importer';
export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';
import { me } from '../initial_state';
export function fetchPinnedStatuses() {
return (dispatch, getState) => {
dispatch(fetchPinnedStatusesRequest());

View file

@ -1,4 +1,5 @@
import api from '../api';
import { importFetchedPoll } from './importer';
export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';

View file

@ -1,5 +1,5 @@
import { setAlerts } from './setter';
import { saveSettings } from './registerer';
import { setAlerts } from './setter';
export function changeAlerts(path, value) {
return dispatch => {

View file

@ -1,14 +1,15 @@
import api from '../../api';
import { decode as decodeBase64 } from '../../utils/base64';
import { pushNotificationsSetting } from '../../settings';
import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
import { me } from '../../initial_state';
import { pushNotificationsSetting } from '../../settings';
import { decode as decodeBase64 } from '../../utils/base64';
import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
// Taken from https://www.npmjs.com/package/web-push
const urlBase64ToUint8Array = (base64String) => {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/-/g, '+')
.replace(/_/g, '/');
return decodeBase64(base64);

View file

@ -1,4 +1,5 @@
import api from '../api';
import { openModal } from './modal';
export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST';
@ -6,9 +7,12 @@ export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS';
export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL';
export const initReport = (account, status) => dispatch =>
dispatch(openModal('REPORT', {
accountId: account.get('id'),
statusId: status?.get('id'),
dispatch(openModal({
modalType: 'REPORT',
modalProps: {
accountId: account.get('id'),
statusId: status?.get('id'),
},
}));
export const submitReport = (params, onSuccess, onFail) => (dispatch, getState) => {

View file

@ -1,4 +1,9 @@
import { fromJS } from 'immutable';
import { searchHistory } from 'mastodon/settings';
import api from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
@ -14,6 +19,8 @@ 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 const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
export function changeSearch(value) {
return {
type: SEARCH_CHANGE,
@ -27,23 +34,24 @@ export function clearSearch() {
};
}
export function submitSearch() {
export function submitSearch(type) {
return (dispatch, getState) => {
const value = getState().getIn(['search', 'value']);
const signedIn = !!getState().getIn(['meta', 'me']);
if (value.length === 0) {
dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, ''));
dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type));
return;
}
dispatch(fetchSearchRequest());
dispatch(fetchSearchRequest(type));
api(getState).get('/api/v2/search', {
params: {
q: value,
resolve: signedIn,
limit: 5,
limit: 11,
type,
},
}).then(response => {
if (response.data.accounts) {
@ -54,7 +62,7 @@ export function submitSearch() {
dispatch(importFetchedStatuses(response.data.statuses));
}
dispatch(fetchSearchSuccess(response.data, value));
dispatch(fetchSearchSuccess(response.data, value, type));
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
}).catch(error => {
dispatch(fetchSearchFail(error));
@ -62,16 +70,18 @@ export function submitSearch() {
};
}
export function fetchSearchRequest() {
export function fetchSearchRequest(searchType) {
return {
type: SEARCH_FETCH_REQUEST,
searchType,
};
}
export function fetchSearchSuccess(results, searchTerm) {
export function fetchSearchSuccess(results, searchTerm, searchType) {
return {
type: SEARCH_FETCH_SUCCESS,
results,
searchType,
searchTerm,
};
}
@ -85,15 +95,16 @@ export function fetchSearchFail(error) {
export const expandSearch = type => (dispatch, getState) => {
const value = getState().getIn(['search', 'value']);
const offset = getState().getIn(['search', 'results', type]).size;
const offset = getState().getIn(['search', 'results', type]).size - 1;
dispatch(expandSearchRequest());
dispatch(expandSearchRequest(type));
api(getState).get('/api/v2/search', {
params: {
q: value,
type,
offset,
limit: 11,
},
}).then(({ data }) => {
if (data.accounts) {
@ -111,8 +122,9 @@ export const expandSearch = type => (dispatch, getState) => {
});
};
export const expandSearchRequest = () => ({
export const expandSearchRequest = (searchType) => ({
type: SEARCH_EXPAND_REQUEST,
searchType,
});
export const expandSearchSuccess = (results, searchTerm, searchType) => ({
@ -130,3 +142,69 @@ export const expandSearchFail = error => ({
export const showSearch = () => ({
type: SEARCH_SHOW,
});
export const openURL = (value, history, onFailure) => (dispatch, getState) => {
const signedIn = !!getState().getIn(['meta', 'me']);
if (!signedIn) {
if (onFailure) {
onFailure();
}
return;
}
dispatch(fetchSearchRequest());
api(getState).get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => {
if (response.data.accounts?.length > 0) {
dispatch(importFetchedAccounts(response.data.accounts));
history.push(`/@${response.data.accounts[0].acct}`);
} else if (response.data.statuses?.length > 0) {
dispatch(importFetchedStatuses(response.data.statuses));
history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`);
} else if (onFailure) {
onFailure();
}
dispatch(fetchSearchSuccess(response.data, value));
}).catch(err => {
dispatch(fetchSearchFail(err));
if (onFailure) {
onFailure();
}
});
};
export const clickSearchResult = (q, type) => (dispatch, getState) => {
const previous = getState().getIn(['search', 'recent']);
const me = getState().getIn(['meta', 'me']);
const current = previous.add(fromJS({ type, q })).takeLast(4);
searchHistory.set(me, current.toJS());
dispatch(updateSearchHistory(current));
};
export const forgetSearchResult = q => (dispatch, getState) => {
const previous = getState().getIn(['search', 'recent']);
const me = getState().getIn(['meta', 'me']);
const current = previous.filterNot(result => result.get('q') === q);
searchHistory.set(me, current.toJS());
dispatch(updateSearchHistory(current));
};
export const updateSearchHistory = recent => ({
type: SEARCH_HISTORY_UPDATE,
recent,
});
export const hydrateSearch = () => (dispatch, getState) => {
const me = getState().getIn(['meta', 'me']);
const history = searchHistory.get(me);
if (history !== null) {
dispatch(updateSearchHistory(history));
}
};

View file

@ -1,10 +1,15 @@
import api from '../api';
import { importFetchedAccount } from './importer';
export const SERVER_FETCH_REQUEST = 'Server_FETCH_REQUEST';
export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS';
export const SERVER_FETCH_FAIL = 'Server_FETCH_FAIL';
export const SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST = 'SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST';
export const SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS = 'SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS';
export const SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL = 'SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL';
export const EXTENDED_DESCRIPTION_REQUEST = 'EXTENDED_DESCRIPTION_REQUEST';
export const EXTENDED_DESCRIPTION_SUCCESS = 'EXTENDED_DESCRIPTION_SUCCESS';
export const EXTENDED_DESCRIPTION_FAIL = 'EXTENDED_DESCRIPTION_FAIL';
@ -14,6 +19,10 @@ export const SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS = 'SERVER_DOMAIN_BLOCKS_FETCH_SU
export const SERVER_DOMAIN_BLOCKS_FETCH_FAIL = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL';
export const fetchServer = () => (dispatch, getState) => {
if (getState().getIn(['server', 'server', 'isLoading'])) {
return;
}
dispatch(fetchServerRequest());
api(getState)
@ -37,7 +46,34 @@ const fetchServerFail = error => ({
error,
});
export const fetchServerTranslationLanguages = () => (dispatch, getState) => {
dispatch(fetchServerTranslationLanguagesRequest());
api(getState)
.get('/api/v1/instance/translation_languages').then(({ data }) => {
dispatch(fetchServerTranslationLanguagesSuccess(data));
}).catch(err => dispatch(fetchServerTranslationLanguagesFail(err)));
};
const fetchServerTranslationLanguagesRequest = () => ({
type: SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST,
});
const fetchServerTranslationLanguagesSuccess = translationLanguages => ({
type: SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS,
translationLanguages,
});
const fetchServerTranslationLanguagesFail = error => ({
type: SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL,
error,
});
export const fetchExtendedDescription = () => (dispatch, getState) => {
if (getState().getIn(['server', 'extendedDescription', 'isLoading'])) {
return;
}
dispatch(fetchExtendedDescriptionRequest());
api(getState)
@ -61,6 +97,10 @@ const fetchExtendedDescriptionFail = error => ({
});
export const fetchDomainBlocks = () => (dispatch, getState) => {
if (getState().getIn(['server', 'domainBlocks', 'isLoading'])) {
return;
}
dispatch(fetchDomainBlocksRequest());
api(getState)

View file

@ -1,5 +1,7 @@
import api from '../api';
import { debounce } from 'lodash';
import api from '../api';
import { showAlertForError } from './alerts';
export const SETTING_CHANGE = 'SETTING_CHANGE';
@ -18,7 +20,7 @@ export function changeSetting(path, value) {
}
const debouncedSave = debounce((dispatch, getState) => {
if (getState().getIn(['settings', 'saved'])) {
if (getState().getIn(['settings', 'saved']) || !getState().getIn(['meta', 'me'])) {
return;
}

View file

@ -1,8 +1,8 @@
import api from '../api';
import { deleteFromTimelines } from './timelines';
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
import { deleteFromTimelines } from './timelines';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@ -343,7 +343,8 @@ export const translateStatusFail = (id, error) => ({
error,
});
export const undoStatusTranslation = id => ({
export const undoStatusTranslation = (id, pollId) => ({
type: STATUS_TRANSLATE_UNDO,
id,
pollId,
});

View file

@ -1,6 +1,8 @@
import { Iterable, fromJS } from 'immutable';
import { hydrateCompose } from './compose';
import { importFetchedAccounts } from './importer';
import { hydrateSearch } from './search';
export const STORE_HYDRATE = 'STORE_HYDRATE';
export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
@ -19,6 +21,7 @@ export function hydrateStore(rawState) {
});
dispatch(hydrateCompose());
dispatch(hydrateSearch());
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
};
}

View file

@ -1,6 +1,17 @@
// @ts-check
import { getLocale } from '../locales';
import { connectStream } from '../stream';
import {
fetchAnnouncements,
updateAnnouncements,
updateReaction as updateAnnouncementsReaction,
deleteAnnouncement,
} from './announcements';
import { updateConversations } from './conversations';
import { updateNotifications, expandNotifications } from './notifications';
import { updateStatus } from './statuses';
import {
updateTimeline,
deleteFromTimelines,
@ -12,22 +23,10 @@ import {
fillCommunityTimelineGaps,
fillListTimelineGaps,
} from './timelines';
import { updateNotifications, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
import { updateStatus } from './statuses';
import {
fetchAnnouncements,
updateAnnouncements,
updateReaction as updateAnnouncementsReaction,
deleteAnnouncement,
} from './announcements';
import { getLocale } from '../locales';
const { messages } = getLocale();
/**
* @param {number} max
* @return {number}
* @returns {number}
*/
const randomUpTo = max =>
Math.floor(Math.random() * Math.floor(max));
@ -40,19 +39,24 @@ const randomUpTo = max =>
* @param {function(Function, Function): void} [options.fallback]
* @param {function(): void} [options.fillGaps]
* @param {function(object): boolean} [options.accept]
* @return {function(): void}
* @returns {function(): void}
*/
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) =>
connectStream(channelName, params, (dispatch, getState) => {
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => {
const { messages } = getLocale();
return connectStream(channelName, params, (dispatch, getState) => {
const locale = getState().getIn(['meta', 'locale']);
// @ts-expect-error
let pollingId;
/**
* @param {function(Function, Function): void} fallback
*/
const useFallback = fallback => {
fallback(dispatch, () => {
// eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook
pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000));
});
};
@ -61,9 +65,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
onConnect() {
dispatch(connectTimeline(timelineId));
// @ts-expect-error
if (pollingId) {
clearTimeout(pollingId);
pollingId = null;
// @ts-ignore
clearTimeout(pollingId); pollingId = null;
}
if (options.fillGaps) {
@ -75,31 +80,38 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
dispatch(disconnectTimeline(timelineId));
if (options.fallback) {
// @ts-expect-error
pollingId = setTimeout(() => useFallback(options.fallback), randomUpTo(40000));
}
},
onReceive (data) {
switch(data.event) {
onReceive(data) {
switch (data.event) {
case 'update':
// @ts-expect-error
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
break;
case 'status.update':
// @ts-expect-error
dispatch(updateStatus(JSON.parse(data.payload)));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
case 'notification':
// @ts-expect-error
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
break;
case 'conversation':
// @ts-expect-error
dispatch(updateConversations(JSON.parse(data.payload)));
break;
case 'announcement':
// @ts-expect-error
dispatch(updateAnnouncements(JSON.parse(data.payload)));
break;
case 'announcement.reaction':
// @ts-expect-error
dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
break;
case 'announcement.delete':
@ -109,27 +121,31 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
},
};
});
};
/**
* @param {Function} dispatch
* @param {function(): void} done
*/
const refreshHomeTimelineAndNotification = (dispatch, done) => {
// @ts-expect-error
dispatch(expandHomeTimeline({}, () =>
// @ts-expect-error
dispatch(expandNotifications({}, () =>
dispatch(fetchAnnouncements(done))))));
};
/**
* @return {function(): void}
* @returns {function(): void}
*/
export const connectUserStream = () =>
// @ts-expect-error
connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps });
/**
* @param {Object} options
* @param {boolean} [options.onlyMedia]
* @return {function(): void}
* @returns {function(): void}
*/
export const connectCommunityStream = ({ onlyMedia } = {}) =>
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) });
@ -138,7 +154,7 @@ export const connectCommunityStream = ({ onlyMedia } = {}) =>
* @param {Object} options
* @param {boolean} [options.onlyMedia]
* @param {boolean} [options.onlyRemote]
* @return {function(): void}
* @returns {function(): void}
*/
export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) =>
connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote }) });
@ -148,20 +164,20 @@ export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) =>
* @param {string} tagName
* @param {boolean} onlyLocal
* @param {function(object): boolean} accept
* @return {function(): void}
* @returns {function(): void}
*/
export const connectHashtagStream = (columnId, tagName, onlyLocal, accept) =>
connectTimelineStream(`hashtag:${columnId}${onlyLocal ? ':local' : ''}`, `hashtag${onlyLocal ? ':local' : ''}`, { tag: tagName }, { accept });
/**
* @return {function(): void}
* @returns {function(): void}
*/
export const connectDirectStream = () =>
connectTimelineStream('direct', 'direct');
/**
* @param {string} listId
* @return {function(): void}
* @returns {function(): void}
*/
export const connectListStream = listId =>
connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { fillGaps: () => fillListTimelineGaps(listId) });

View file

@ -1,6 +1,7 @@
import api from '../api';
import { importFetchedAccounts } from './importer';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';

View file

@ -1,9 +1,11 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import api, { getLinks } from 'mastodon/api';
import { compareId } from 'mastodon/compare_id';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import { importFetchedStatus, importFetchedStatuses } from './importer';
import { submitMarkers } from './markers';
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';
@ -143,7 +145,7 @@ export function fillTimelineGaps(timelineId, path, params = {}, done = noOp) {
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, tagged, max_id: maxId });
export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, exclude_reblogs: withReplies, tagged, max_id: maxId });
export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST';

View file

@ -1,75 +0,0 @@
// @ts-check
import axios from 'axios';
import LinkHeader from 'http-link-header';
import ready from './ready';
/**
* @param {import('axios').AxiosResponse} response
* @returns {LinkHeader}
*/
export const getLinks = response => {
const value = response.headers.link;
if (!value) {
return new LinkHeader();
}
return LinkHeader.parse(value);
};
/** @type {import('axios').RawAxiosRequestHeaders} */
const csrfHeader = {};
/**
* @returns {void}
*/
const setCSRFHeader = () => {
/** @type {HTMLMetaElement | null} */
const csrfToken = document.querySelector('meta[name=csrf-token]');
if (csrfToken) {
csrfHeader['X-CSRF-Token'] = csrfToken.content;
}
};
ready(setCSRFHeader);
/**
* @param {() => import('immutable').Map} getState
* @returns {import('axios').RawAxiosRequestHeaders}
*/
const authorizationHeaderFromState = getState => {
const accessToken = getState && getState().getIn(['meta', 'access_token'], '');
if (!accessToken) {
return {};
}
return {
'Authorization': `Bearer ${accessToken}`,
};
};
/**
* @param {() => import('immutable').Map} getState
* @returns {import('axios').AxiosInstance}
*/
export default function api(getState) {
return axios.create({
headers: {
...csrfHeader,
...authorizationHeaderFromState(getState),
},
transformResponse: [
function (data) {
try {
return JSON.parse(data);
} catch {
return data;
}
},
],
});
}

View file

@ -0,0 +1,63 @@
import type { AxiosResponse, RawAxiosRequestHeaders } from 'axios';
import axios from 'axios';
import LinkHeader from 'http-link-header';
import ready from './ready';
import type { GetState } from './store';
export const getLinks = (response: AxiosResponse) => {
const value = response.headers.link as string | undefined;
if (!value) {
return new LinkHeader();
}
return LinkHeader.parse(value);
};
const csrfHeader: RawAxiosRequestHeaders = {};
const setCSRFHeader = () => {
const csrfToken = document.querySelector<HTMLMetaElement>(
'meta[name=csrf-token]',
);
if (csrfToken) {
csrfHeader['X-CSRF-Token'] = csrfToken.content;
}
};
void ready(setCSRFHeader);
const authorizationHeaderFromState = (getState?: GetState) => {
const accessToken =
getState && (getState().meta.get('access_token', '') as string);
if (!accessToken) {
return {};
}
return {
Authorization: `Bearer ${accessToken}`,
} as RawAxiosRequestHeaders;
};
// eslint-disable-next-line import/no-default-export
export default function api(getState: GetState) {
return axios.create({
headers: {
...csrfHeader,
...authorizationHeaderFromState(getState),
},
transformResponse: [
function (data: unknown) {
try {
return JSON.parse(data as string) as unknown;
} catch {
return data;
}
},
],
});
}

View file

@ -1,47 +0,0 @@
import 'intl';
import 'intl/locale-data/jsonp/en';
import 'es6-symbol/implement';
import includes from 'array-includes';
import assign from 'object-assign';
import values from 'object.values';
import isNaN from 'is-nan';
import { decode as decodeBase64 } from './utils/base64';
import promiseFinally from 'promise.prototype.finally';
if (!Array.prototype.includes) {
includes.shim();
}
if (!Object.assign) {
Object.assign = assign;
}
if (!Object.values) {
values.shim();
}
if (!Number.isNaN) {
Number.isNaN = isNaN;
}
promiseFinally.shim();
if (!HTMLCanvasElement.prototype.toBlob) {
const BASE64_MARKER = ';base64,';
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
value(callback, type = 'image/png', quality) {
const dataURL = this.toDataURL(type, quality);
let data;
if (dataURL.indexOf(BASE64_MARKER) >= 0) {
const [, base64] = dataURL.split(BASE64_MARKER);
data = decodeBase64(base64);
} else {
[, data] = dataURL.split(',');
}
callback(new Blob([data], { type }));
},
});
}

View file

@ -84,12 +84,11 @@ const DIGIT_CHARACTERS = [
'~',
];
export const decode83 = (str) => {
export const decode83 = (str: string) => {
let value = 0;
let c, digit;
let digit;
for (let i = 0; i < str.length; i++) {
c = str[i];
for (const c of str) {
digit = DIGIT_CHARACTERS.indexOf(c);
value = value * 83 + digit;
}
@ -97,13 +96,13 @@ export const decode83 = (str) => {
return value;
};
export const intToRGB = int => ({
r: Math.max(0, (int >> 16)),
export const intToRGB = (int: number) => ({
r: Math.max(0, int >> 16),
g: Math.max(0, (int >> 8) & 255),
b: Math.max(0, (int & 255)),
b: Math.max(0, int & 255),
});
export const getAverageFromBlurhash = blurhash => {
export const getAverageFromBlurhash = (blurhash: string) => {
if (!blurhash) {
return null;
}

View file

@ -1,7 +1,7 @@
import Rails from '@rails/ujs';
import 'font-awesome/css/font-awesome.css';
export function start() {
require('font-awesome/css/font-awesome.css');
require.context('../images/', true);
try {

View file

@ -1,4 +1,4 @@
export default function compareId (id1, id2) {
export function compareId(id1: string, id2: string) {
if (id1 === id2) {
return 0;
}

View file

@ -3,6 +3,8 @@
exports[`<AvatarOverlay renders a overlay avatar 1`] = `
<div
className="account__avatar-overlay"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
{
"height": 46,
@ -15,8 +17,6 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
>
<div
className="account__avatar"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
{
"height": "36px",
@ -35,8 +35,6 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
>
<div
className="account__avatar"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
{
"height": "24px",

View file

@ -1,5 +1,5 @@
import React from 'react';
import renderer from 'react-test-renderer';
import AutosuggestEmoji from '../autosuggest_emoji';
describe('<AutosuggestEmoji />', () => {

View file

@ -1,7 +1,8 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import Avatar from '../avatar';
import renderer from 'react-test-renderer';
import { Avatar } from '../avatar';
describe('<Avatar />', () => {
const account = fromJS({

View file

@ -1,7 +1,8 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import AvatarOverlay from '../avatar_overlay';
import renderer from 'react-test-renderer';
import { AvatarOverlay } from '../avatar_overlay';
describe('<AvatarOverlay', () => {
const account = fromJS({

View file

@ -1,6 +1,6 @@
import { render, fireEvent, screen } from '@testing-library/react';
import React from 'react';
import renderer from 'react-test-renderer';
import Button from '../button';
describe('<Button />', () => {

View file

@ -1,7 +1,8 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import DisplayName from '../display_name';
import renderer from 'react-test-renderer';
import { DisplayName } from '../display_name';
describe('<DisplayName />', () => {
it('renders display name + account name', () => {

View file

@ -0,0 +1,214 @@
import { fromJS } from 'immutable';
import type { StatusLike } from '../hashtag_bar';
import { computeHashtagBarForStatus } from '../hashtag_bar';
function createStatus(
content: string,
hashtags: string[],
hasMedia = false,
spoilerText?: string,
) {
return fromJS({
tags: hashtags.map((name) => ({ name })),
contentHtml: content,
media_attachments: hasMedia ? ['fakeMedia'] : [],
spoiler_text: spoilerText,
}) as unknown as StatusLike; // need to force the type here, as it is not properly defined
}
describe('computeHashtagBarForStatus', () => {
it('does nothing when there are no tags', () => {
const status = createStatus('<p>Simple text</p>', []);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Simple text</p>"`,
);
});
it('displays out of band hashtags in the bar', () => {
const status = createStatus(
'<p>Simple text <a href="test">#hashtag</a></p>',
['hashtag', 'test'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual(['test']);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Simple text <a href="test">#hashtag</a></p>"`,
);
});
it('does not truncate the contents when the last child is a text node', () => {
const status = createStatus(
'this is a #<a class="zrl" href="https://example.com/search?tag=test">test</a>. Some more text',
['test'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"this is a #<a class="zrl" href="https://example.com/search?tag=test">test</a>. Some more text"`,
);
});
it('extract tags from the last line', () => {
const status = createStatus(
'<p>Simple text</p><p><a href="test">#hashtag</a></p>',
['hashtag'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual(['hashtag']);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Simple text</p>"`,
);
});
it('does not include tags from content', () => {
const status = createStatus(
'<p>Simple text with a <a href="test">#hashtag</a></p><p><a href="test">#hashtag</a></p>',
['hashtag'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Simple text with a <a href="test">#hashtag</a></p>"`,
);
});
it('works with one line status and hashtags', () => {
const status = createStatus(
'<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>',
['hashtag', 'test'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>"`,
);
});
it('de-duplicate accentuated characters with case differences', () => {
const status = createStatus(
'<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>',
['éaa'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual(['Éaa']);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Text</p>"`,
);
});
it('handles server-side normalized tags with accentuated characters', () => {
const status = createStatus(
'<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>',
['eaa'], // The server may normalize the hashtags in the `tags` attribute
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual(['Éaa']);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Text</p>"`,
);
});
it('does not display in bar a hashtag in content with a case difference', () => {
const status = createStatus(
'<p>Text <a href="test">#Éaa</a></p><p><a href="test">#éaa</a></p>',
['éaa'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Text <a href="test">#Éaa</a></p>"`,
);
});
it('does not modify a status with a line of hashtags only', () => {
const status = createStatus(
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>',
['test', 'hashtag'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p><a href="test">#test</a> <a href="test">#hashtag</a></p>"`,
);
});
it('puts the hashtags in the bar if a status content has hashtags in the only line and has a media', () => {
const status = createStatus(
'<p>This is my content! <a href="test">#hashtag</a></p>',
['hashtag'],
true,
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>This is my content! <a href="test">#hashtag</a></p>"`,
);
});
it('puts the hashtags in the bar if a status content is only hashtags and has a media', () => {
const status = createStatus(
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>',
['test', 'hashtag'],
true,
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual(['test', 'hashtag']);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(`""`);
});
it('does not use the hashtag bar if the status content is only hashtags, has a CW and a media', () => {
const status = createStatus(
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>',
['test', 'hashtag'],
true,
'My CW text',
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p><a href="test">#test</a> <a href="test">#hashtag</a></p>"`,
);
});
});

View file

@ -1,157 +0,0 @@
import React, { Fragment } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from './avatar';
import DisplayName from './display_name';
import IconButton from './icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state';
import RelativeTimestamp from './relative_timestamp';
import Skeleton from 'mastodon/components/skeleton';
import { Link } from 'react-router-dom';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
});
export default @injectIntl
class Account extends ImmutablePureComponent {
static propTypes = {
size: PropTypes.number,
account: ImmutablePropTypes.map,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onMuteNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hidden: PropTypes.bool,
actionIcon: PropTypes.string,
actionTitle: PropTypes.string,
defaultAction: PropTypes.string,
onActionClick: PropTypes.func,
};
static defaultProps = {
size: 46,
};
handleFollow = () => {
this.props.onFollow(this.props.account);
};
handleBlock = () => {
this.props.onBlock(this.props.account);
};
handleMute = () => {
this.props.onMute(this.props.account);
};
handleMuteNotifications = () => {
this.props.onMuteNotifications(this.props.account, true);
};
handleUnmuteNotifications = () => {
this.props.onMuteNotifications(this.props.account, false);
};
handleAction = () => {
this.props.onActionClick(this.props.account);
};
render () {
const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size } = this.props;
if (!account) {
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Skeleton width={36} height={36} /></div>
<DisplayName />
</div>
</div>
</div>
);
}
if (hidden) {
return (
<Fragment>
{account.get('display_name')}
{account.get('username')}
</Fragment>
);
}
let buttons;
if (actionIcon) {
if (onActionClick) {
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
}
} else 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) {
let hidingNotificationsButton;
if (account.getIn(['relationship', 'muting_notifications'])) {
hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />;
} else {
hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />;
}
buttons = (
<Fragment>
<IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />
{hidingNotificationsButton}
</Fragment>
);
} else if (defaultAction === 'mute') {
buttons = <IconButton icon='volume-off' title={intl.formatMessage(messages.mute, { name: account.get('username') })} onClick={this.handleMute} />;
} else if (defaultAction === 'block') {
buttons = <IconButton icon='lock' title={intl.formatMessage(messages.block, { name: account.get('username') })} onClick={this.handleBlock} />;
} 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} />;
}
}
let mute_expires_at;
if (account.get('mute_expires_at')) {
mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>;
}
return (
<div className='account'>
<div className='account__wrapper'>
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={size} /></div>
{mute_expires_at}
<DisplayName account={account} />
</Link>
<div className='account__relationship'>
{buttons}
</div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,190 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { EmptyAccount } from 'mastodon/components/empty_account';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { me } from '../initial_state';
import { Avatar } from './avatar';
import Button from './button';
import { FollowersCounter } from './counters';
import { DisplayName } from './display_name';
import { IconButton } from './icon_button';
import { RelativeTimestamp } from './relative_timestamp';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' },
mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
block: { id: 'account.block_short', defaultMessage: 'Block' },
});
class Account extends ImmutablePureComponent {
static propTypes = {
size: PropTypes.number,
account: ImmutablePropTypes.map,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onMuteNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hidden: PropTypes.bool,
minimal: PropTypes.bool,
actionIcon: PropTypes.string,
actionTitle: PropTypes.string,
defaultAction: PropTypes.string,
onActionClick: PropTypes.func,
withBio: PropTypes.bool,
};
static defaultProps = {
size: 46,
};
handleFollow = () => {
this.props.onFollow(this.props.account);
};
handleBlock = () => {
this.props.onBlock(this.props.account);
};
handleMute = () => {
this.props.onMute(this.props.account);
};
handleMuteNotifications = () => {
this.props.onMuteNotifications(this.props.account, true);
};
handleUnmuteNotifications = () => {
this.props.onMuteNotifications(this.props.account, false);
};
handleAction = () => {
this.props.onActionClick(this.props.account);
};
render () {
const { account, intl, hidden, withBio, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal } = this.props;
if (!account) {
return <EmptyAccount size={size} minimal={minimal} />;
}
if (hidden) {
return (
<>
{account.get('display_name')}
{account.get('username')}
</>
);
}
let buttons;
if (actionIcon && onActionClick) {
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
} else if (!actionIcon && 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 = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={this.handleFollow} />;
} else if (blocking) {
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
} else if (muting) {
let hidingNotificationsButton;
if (account.getIn(['relationship', 'muting_notifications'])) {
hidingNotificationsButton = <Button text={intl.formatMessage(messages.unmute_notifications)} onClick={this.handleUnmuteNotifications} />;
} else {
hidingNotificationsButton = <Button text={intl.formatMessage(messages.mute_notifications)} onClick={this.handleMuteNotifications} />;
}
buttons = (
<>
<Button text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />
{hidingNotificationsButton}
</>
);
} else if (defaultAction === 'mute') {
buttons = <Button title={intl.formatMessage(messages.mute)} onClick={this.handleMute} />;
} else if (defaultAction === 'block') {
buttons = <Button text={intl.formatMessage(messages.block)} onClick={this.handleBlock} />;
} else if (!account.get('moved') || following) {
buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
}
}
let muteTimeRemaining;
if (account.get('mute_expires_at')) {
muteTimeRemaining = <>· <RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></>;
}
let verification;
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
if (firstVerifiedField) {
verification = <VerifiedBadge link={firstVerifiedField.get('value')} />;
}
return (
<div className={classNames('account', { 'account--minimal': minimal })}>
<div className='account__wrapper'>
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'>
<Avatar account={account} size={size} />
</div>
<div className='account__contents'>
<DisplayName account={account} />
{!minimal && (
<div className='account__details'>
<ShortNumber value={account.get('followers_count')} renderer={FollowersCounter} /> {verification} {muteTimeRemaining}
</div>
)}
</div>
</Link>
{!minimal && (
<div className='account__relationship'>
{buttons}
</div>
)}
</div>
{withBio && (account.get('note').length > 0 ? (
<div
className='account__note translate'
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
/>
) : (
<div className='account__note account__note--missing'><FormattedMessage id='account.no_bio' defaultMessage='No description provided.' /></div>
))}
</div>
);
}
}
export default injectIntl(Account);

View file

@ -1,10 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { PureComponent } from 'react';
import { FormattedNumber } from 'react-intl';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import classNames from 'classnames';
import Skeleton from 'mastodon/components/skeleton';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import api from 'mastodon/api';
import { Skeleton } from 'mastodon/components/skeleton';
const percIncrease = (a, b) => {
let percent;
@ -24,7 +28,7 @@ const percIncrease = (a, b) => {
return percent;
};
export default class Counter extends React.PureComponent {
export default class Counter extends PureComponent {
static propTypes = {
measure: PropTypes.string.isRequired,
@ -62,25 +66,25 @@ export default class Counter extends React.PureComponent {
if (loading) {
content = (
<React.Fragment>
<>
<span className='sparkline__value__total'><Skeleton width={43} /></span>
<span className='sparkline__value__change'><Skeleton width={43} /></span>
</React.Fragment>
</>
);
} else {
const measure = data[0];
const percentChange = measure.previous_total && percIncrease(measure.previous_total * 1, measure.total * 1);
content = (
<React.Fragment>
<>
<span className='sparkline__value__total'>{measure.human_value || <FormattedNumber value={measure.total} />}</span>
{measure.previous_total && (<span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>)}
</React.Fragment>
</>
);
}
const inner = (
<React.Fragment>
<>
<div className='sparkline__value'>
{content}
</div>
@ -96,7 +100,7 @@ export default class Counter extends React.PureComponent {
</Sparklines>
)}
</div>
</React.Fragment>
</>
);
if (href) {

View file

@ -1,11 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { FormattedNumber } from 'react-intl';
import { roundTo10 } from 'mastodon/utils/numbers';
import Skeleton from 'mastodon/components/skeleton';
import { PureComponent } from 'react';
export default class Dimension extends React.PureComponent {
import { FormattedNumber } from 'react-intl';
import api from 'mastodon/api';
import { Skeleton } from 'mastodon/components/skeleton';
import { roundTo10 } from 'mastodon/utils/numbers';
export default class Dimension extends PureComponent {
static propTypes = {
dimension: PropTypes.string.isRequired,

View file

@ -0,0 +1,91 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedNumber, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import api from 'mastodon/api';
import { Skeleton } from 'mastodon/components/skeleton';
export default class ImpactReport extends PureComponent {
static propTypes = {
domain: PropTypes.string.isRequired,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { domain } = this.props;
const params = {
domain: domain,
include_subdomains: true,
};
api().post('/api/v1/admin/measures', {
keys: ['instance_accounts', 'instance_follows', 'instance_followers'],
start_at: null,
end_at: null,
instance_accounts: params,
instance_follows: params,
instance_followers: params,
}).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { loading, data } = this.state;
return (
<div className='dimension'>
<h4><FormattedMessage id='admin.impact_report.title' defaultMessage='Impact summary' /></h4>
<table>
<tbody>
<tr className='dimension__item'>
<td className='dimension__item__key'>
<FormattedMessage id='admin.impact_report.instance_accounts' defaultMessage='Accounts profiles this would delete' />
</td>
<td className='dimension__item__value'>
{loading ? <Skeleton width={60} /> : <FormattedNumber value={data[0].total} />}
</td>
</tr>
<tr className={classNames('dimension__item', { negative: !loading && data[1].total > 0 })}>
<td className='dimension__item__key'>
<FormattedMessage id='admin.impact_report.instance_follows' defaultMessage='Followers their users would lose' />
</td>
<td className='dimension__item__value'>
{loading ? <Skeleton width={60} /> : <FormattedNumber value={data[1].total} />}
</td>
</tr>
<tr className={classNames('dimension__item', { negative: !loading && data[2].total > 0 })}>
<td className='dimension__item__key'>
<FormattedMessage id='admin.impact_report.instance_followers' defaultMessage='Followers our users would lose' />
</td>
<td className='dimension__item__value'>
{loading ? <Skeleton width={60} /> : <FormattedNumber value={data[2].total} />}
</td>
</tr>
</tbody>
</table>
</div>
);
}
}

View file

@ -1,16 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import api from 'mastodon/api';
const messages = defineMessages({
legal: { id: 'report.categories.legal', defaultMessage: 'Legal' },
other: { id: 'report.categories.other', defaultMessage: 'Other' },
spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
});
class Category extends React.PureComponent {
class Category extends PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
@ -33,7 +37,7 @@ class Category extends React.PureComponent {
const { id, text, disabled, selected, children } = this.props;
return (
<div tabIndex='0' role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}>
<div tabIndex={0} role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}>
{selected && <input type='hidden' name='report[category]' value={id} />}
<div className='report-reason-selector__category__label'>
@ -52,7 +56,7 @@ class Category extends React.PureComponent {
}
class Rule extends React.PureComponent {
class Rule extends PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
@ -74,7 +78,7 @@ class Rule extends React.PureComponent {
const { id, text, disabled, selected } = this.props;
return (
<div tabIndex='0' role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}>
<div tabIndex={0} role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}>
<span className={classNames('poll__input', { checkbox: true, active: selected, disabled })} />
{selected && <input type='hidden' name='report[rule_ids][]' value={id} />}
{text}
@ -84,8 +88,7 @@ class Rule extends React.PureComponent {
}
export default @injectIntl
class ReportReasonSelector extends React.PureComponent {
class ReportReasonSelector extends PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
@ -121,7 +124,7 @@ class ReportReasonSelector extends React.PureComponent {
api().put(`/api/v1/admin/reports/${id}`, {
category,
rule_ids,
rule_ids: category === 'violation' ? rule_ids : [],
}).catch(err => {
console.error(err);
});
@ -148,6 +151,7 @@ class ReportReasonSelector extends React.PureComponent {
return (
<div className='report-reason-selector'>
<Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} />
<Category id='legal' text={intl.formatMessage(messages.legal)} selected={category === 'legal'} onSelect={this.handleSelect} disabled={disabled} />
<Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} />
<Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}>
{rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)}
@ -157,3 +161,5 @@ class ReportReasonSelector extends React.PureComponent {
}
}
export default injectIntl(ReportReasonSelector);

View file

@ -1,20 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { PureComponent } from 'react';
import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
import classNames from 'classnames';
import api from 'mastodon/api';
import { roundTo10 } from 'mastodon/utils/numbers';
const dateForCohort = cohort => {
const timeZone = 'UTC';
switch(cohort.frequency) {
case 'day':
return <FormattedDate value={cohort.period} month='long' day='2-digit' />;
return <FormattedDate value={cohort.period} month='long' day='2-digit' timeZone={timeZone} />;
default:
return <FormattedDate value={cohort.period} month='long' year='numeric' />;
return <FormattedDate value={cohort.period} month='long' year='numeric' timeZone={timeZone} />;
}
};
export default class Retention extends React.PureComponent {
export default class Retention extends PureComponent {
static propTypes = {
start_at: PropTypes.string,

View file

@ -1,11 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import api from 'mastodon/api';
import Hashtag from 'mastodon/components/hashtag';
export default class Trends extends React.PureComponent {
export default class Trends extends PureComponent {
static propTypes = {
limit: PropTypes.number.isRequired,

View file

@ -1,76 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ShortNumber from 'mastodon/components/short_number';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
import { reduceMotion } from 'mastodon/initial_state';
const obfuscatedCount = count => {
if (count < 0) {
return 0;
} else if (count <= 1) {
return count;
} else {
return '1+';
}
};
export default class AnimatedNumber extends React.PureComponent {
static propTypes = {
value: PropTypes.number.isRequired,
obfuscate: PropTypes.bool,
};
state = {
direction: 1,
};
componentWillReceiveProps (nextProps) {
if (nextProps.value > this.props.value) {
this.setState({ direction: 1 });
} else if (nextProps.value < this.props.value) {
this.setState({ direction: -1 });
}
}
willEnter = () => {
const { direction } = this.state;
return { y: -1 * direction };
};
willLeave = () => {
const { direction } = this.state;
return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) };
};
render () {
const { value, obfuscate } = this.props;
const { direction } = this.state;
if (reduceMotion) {
return obfuscate ? obfuscatedCount(value) : <ShortNumber value={value} />;
}
const styles = [{
key: `${value}`,
data: value,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
}];
return (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<span className='animated-number'>
{items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span>
))}
</span>
)}
</TransitionMotion>
);
}
}

View file

@ -0,0 +1,62 @@
import { useCallback, useState } from 'react';
import { TransitionMotion, spring } from 'react-motion';
import { reduceMotion } from '../initial_state';
import { ShortNumber } from './short_number';
interface Props {
value: number;
}
export const AnimatedNumber: React.FC<Props> = ({ value }) => {
const [previousValue, setPreviousValue] = useState(value);
const [direction, setDirection] = useState<1 | -1>(1);
if (previousValue !== value) {
setPreviousValue(value);
setDirection(value > previousValue ? 1 : -1);
}
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
const willLeave = useCallback(
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
[direction],
);
if (reduceMotion) {
return <ShortNumber value={value} />;
}
const styles = [
{
key: `${value}`,
data: value,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
},
];
return (
<TransitionMotion
styles={styles}
willEnter={willEnter}
willLeave={willLeave}
>
{(items) => (
<span className='animated-number'>
{items.map(({ key, data, style }) => (
<span
key={key}
style={{
position: direction * style.y > 0 ? 'absolute' : 'static',
transform: `translateY(${style.y * 100}%)`,
}}
>
<ShortNumber value={data as number} />
</span>
))}
</span>
)}
</TransitionMotion>
);
};

View file

@ -1,10 +1,13 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Icon } from 'mastodon/components/icon';
const filename = url => url.split('/').pop().split('#')[0].split('?')[0];

View file

@ -1,9 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
import { PureComponent } from 'react';
import { assetHost } from 'mastodon/utils/config';
export default class AutosuggestEmoji extends React.PureComponent {
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
export default class AutosuggestEmoji extends PureComponent {
static propTypes = {
emoji: PropTypes.object.isRequired,

View file

@ -1,42 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ShortNumber from 'mastodon/components/short_number';
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 && (
<ShortNumber
value={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>
);
}
}

View file

@ -0,0 +1,42 @@
import { FormattedMessage } from 'react-intl';
import { ShortNumber } from 'mastodon/components/short_number';
interface Props {
tag: {
name: string;
url?: string;
history?: {
uses: number;
accounts: string;
day: string;
}[];
following?: boolean;
type: 'hashtag';
};
}
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => {
const weeklyUses = tag.history && (
<ShortNumber
value={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>
);
};

View file

@ -1,12 +1,15 @@
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 ImmutablePureComponent from 'react-immutable-pure-component';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
let word;
@ -51,7 +54,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
searchTokens: PropTypes.arrayOf(PropTypes.string),
maxLength: PropTypes.number,
lang: PropTypes.string,
spellCheck: PropTypes.string,
spellCheck: PropTypes.bool,
};
static defaultProps = {
@ -154,7 +157,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
this.input.focus();
};
componentWillReceiveProps (nextProps) {
UNSAFE_componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
@ -180,7 +183,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
}
return (
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
<div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
{inner}
</div>
);

View file

@ -1,13 +1,17 @@
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 ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;
@ -153,7 +157,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
this.textarea.focus();
};
componentWillReceiveProps (nextProps) {
UNSAFE_componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
@ -186,7 +190,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
}
return (
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
<div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
{inner}
</div>
);

View file

@ -1,62 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state';
import classNames from 'classnames';
export default class Avatar extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
size: PropTypes.number.isRequired,
style: PropTypes.object,
inline: PropTypes.bool,
animate: PropTypes.bool,
};
static defaultProps = {
animate: autoPlayGif,
size: 20,
inline: false,
};
state = {
hovering: false,
};
handleMouseEnter = () => {
if (this.props.animate) return;
this.setState({ hovering: true });
};
handleMouseLeave = () => {
if (this.props.animate) return;
this.setState({ hovering: false });
};
render () {
const { account, size, animate, inline } = this.props;
const { hovering } = this.state;
const style = {
...this.props.style,
width: `${size}px`,
height: `${size}px`,
};
let src;
if (hovering || animate) {
src = account?.get('avatar');
} else {
src = account?.get('avatar_static');
}
return (
<div className={classNames('account__avatar', { 'account__avatar-inline': inline })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={style}>
{src && <img src={src} alt={account?.get('acct')} />}
</div>
);
}
}

View file

@ -0,0 +1,47 @@
import classNames from 'classnames';
import { useHovering } from '../../hooks/useHovering';
import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';
interface Props {
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
size: number;
style?: React.CSSProperties;
inline?: boolean;
animate?: boolean;
}
export const Avatar: React.FC<Props> = ({
account,
animate = autoPlayGif,
size = 20,
inline = false,
style: styleFromParent,
}) => {
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate);
const style = {
...styleFromParent,
width: `${size}px`,
height: `${size}px`,
};
const src =
hovering || animate
? account?.get('avatar')
: account?.get('avatar_static');
return (
<div
className={classNames('account__avatar', {
'account__avatar-inline': inline,
})}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={style}
>
{src && <img src={src} alt={account?.get('acct')} />}
</div>
);
};

View file

@ -1,10 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state';
import Avatar from './avatar';
import { PureComponent } from 'react';
export default class AvatarComposite extends React.PureComponent {
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state';
import { Avatar } from './avatar';
export default class AvatarComposite extends PureComponent {
static propTypes = {
accounts: ImmutablePropTypes.list.isRequired,

View file

@ -1,51 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state';
import Avatar from './avatar';
export default class AvatarOverlay extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map.isRequired,
animate: PropTypes.bool,
size: PropTypes.number,
baseSize: PropTypes.number,
overlaySize: PropTypes.number,
};
static defaultProps = {
animate: autoPlayGif,
size: 46,
baseSize: 36,
overlaySize: 24,
};
state = {
hovering: false,
};
handleMouseEnter = () => {
if (this.props.animate) return;
this.setState({ hovering: true });
};
handleMouseLeave = () => {
if (this.props.animate) return;
this.setState({ hovering: false });
};
render() {
const { account, friend, animate, size, baseSize, overlaySize } = this.props;
const { hovering } = this.state;
return (
<div className='account__avatar-overlay' style={{ width: size, height: size }}>
<div className='account__avatar-overlay-base'><Avatar animate={hovering || animate} account={account} size={baseSize} /></div>
<div className='account__avatar-overlay-overlay'><Avatar animate={hovering || animate} account={friend} size={overlaySize} /></div>
</div>
);
}
}

View file

@ -0,0 +1,54 @@
import { useHovering } from '../../hooks/useHovering';
import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';
interface Props {
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
friend: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
size?: number;
baseSize?: number;
overlaySize?: number;
}
export const AvatarOverlay: React.FC<Props> = ({
account,
friend,
size = 46,
baseSize = 36,
overlaySize = 24,
}) => {
const { hovering, handleMouseEnter, handleMouseLeave } =
useHovering(autoPlayGif);
const accountSrc = hovering
? account?.get('avatar')
: account?.get('avatar_static');
const friendSrc = hovering
? friend?.get('avatar')
: friend?.get('avatar_static');
return (
<div
className='account__avatar-overlay'
style={{ width: size, height: size }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className='account__avatar-overlay-base'>
<div
className='account__avatar'
style={{ width: `${baseSize}px`, height: `${baseSize}px` }}
>
{accountSrc && <img src={accountSrc} alt={account?.get('acct')} />}
</div>
</div>
<div className='account__avatar-overlay-overlay'>
<div
className='account__avatar'
style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }}
>
{friendSrc && <img src={friendSrc} alt={friend?.get('acct')} />}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,34 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { ReactComponent as GroupsIcon } from '@material-design-icons/svg/outlined/group.svg';
import { ReactComponent as PersonIcon } from '@material-design-icons/svg/outlined/person.svg';
import { ReactComponent as SmartToyIcon } from '@material-design-icons/svg/outlined/smart_toy.svg';
export const Badge = ({ icon, label, domain }) => (
<div className='account-role'>
{icon}
{label}
{domain && <span className='account-role__domain'>{domain}</span>}
</div>
);
Badge.propTypes = {
icon: PropTypes.node,
label: PropTypes.node,
domain: PropTypes.node,
};
Badge.defaultProps = {
icon: <PersonIcon />,
};
export const GroupBadge = () => (
<Badge icon={<GroupsIcon />} label={<FormattedMessage id='account.badges.group' defaultMessage='Group' />} />
);
export const AutomatedBadge = () => (
<Badge icon={<SmartToyIcon />} label={<FormattedMessage id='account.badges.bot' defaultMessage='Automated' />} />
);

View file

@ -1,65 +0,0 @@
// @ts-check
import { decode } from 'blurhash';
import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
/**
* @typedef BlurhashPropsBase
* @property {string?} hash Hash to render
* @property {number} width
* Width of the blurred region in pixels. Defaults to 32
* @property {number} [height]
* Height of the blurred region in pixels. Defaults to width
* @property {boolean} [dummy]
* Whether dummy mode is enabled. If enabled, nothing is rendered
* and canvas left untouched
*/
/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */
/**
* Component that is used to render blurred of blurhash string
*
* @param {BlurhashProps} param1 Props of the component
* @returns Canvas which will render blurred region element to embed
*/
function Blurhash({
hash,
width = 32,
height = width,
dummy = false,
...canvasProps
}) {
const canvasRef = /** @type {import('react').MutableRefObject<HTMLCanvasElement>} */ (useRef());
useEffect(() => {
const { current: canvas } = canvasRef;
canvas.width = canvas.width; // resets canvas
if (dummy || !hash) return;
try {
const pixels = decode(hash, width, height);
const ctx = canvas.getContext('2d');
const imageData = new ImageData(pixels, width, height);
ctx.putImageData(imageData, 0, 0);
} catch (err) {
console.error('Blurhash decoding failure', { err, hash });
}
}, [dummy, hash, width, height]);
return (
<canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
);
}
Blurhash.propTypes = {
hash: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
dummy: PropTypes.bool,
};
export default React.memo(Blurhash);

View file

@ -0,0 +1,48 @@
import { memo, useRef, useEffect } from 'react';
import { decode } from 'blurhash';
interface Props extends React.HTMLAttributes<HTMLCanvasElement> {
hash: string;
width?: number;
height?: number;
dummy?: boolean; // Whether dummy mode is enabled. If enabled, nothing is rendered and canvas left untouched
children?: never;
}
const Blurhash: React.FC<Props> = ({
hash,
width = 32,
height = width,
dummy = false,
...canvasProps
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const canvas = canvasRef.current!;
// eslint-disable-next-line no-self-assign
canvas.width = canvas.width; // resets canvas
if (dummy || !hash) return;
try {
const pixels = decode(hash, width, height);
const ctx = canvas.getContext('2d');
const imageData = new ImageData(pixels, width, height);
ctx?.putImageData(imageData, 0, 0);
} catch (err) {
console.error('Blurhash decoding failure', { err, hash });
}
}, [dummy, hash, width, height]);
return (
<canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
);
};
const MemoizedBlurhash = memo(Blurhash);
export { MemoizedBlurhash as Blurhash };

View file

@ -1,8 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import classNames from 'classnames';
export default class Button extends React.PureComponent {
export default class Button extends PureComponent {
static propTypes = {
text: PropTypes.node,

View file

@ -1,9 +0,0 @@
import React from 'react';
const Check = () => (
<svg width='14' height='11' viewBox='0 0 14 11'>
<path d='M11.264 0L5.26 6.004 2.103 2.847 0 4.95l5.26 5.26 8.108-8.107L11.264 0' fill='currentColor' fillRule='evenodd' />
</svg>
);
export default Check;

View file

@ -0,0 +1,13 @@
export const Check: React.FC = () => (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 20 20'
fill='currentColor'
>
<path
fillRule='evenodd'
d='M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z'
clipRule='evenodd'
/>
</svg>
);

View file

@ -0,0 +1,27 @@
interface Props {
size: number;
strokeWidth: number;
}
export const CircularProgress: React.FC<Props> = ({ size, strokeWidth }) => {
const viewBox = `0 0 ${size} ${size}`;
const radius = (size - strokeWidth) / 2;
return (
<svg
width={size}
height={size}
viewBox={viewBox}
className='circular-progress'
role='progressbar'
>
<circle
fill='none'
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={`${strokeWidth}px`}
/>
</svg>
);
};

View file

@ -1,9 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { supportsPassiveEvents } from 'detect-passive-events';
import { scrollTop } from '../scroll';
export default class Column extends React.PureComponent {
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
export default class Column extends PureComponent {
static propTypes = {
children: PropTypes.node,
@ -12,7 +16,13 @@ export default class Column extends React.PureComponent {
};
scrollTop () {
const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable');
let scrollable = null;
if (this.props.bindToDocument) {
scrollable = document.scrollingElement;
} else {
scrollable = this.node.querySelector('.scrollable');
}
if (!scrollable) {
return;
@ -35,17 +45,17 @@ export default class Column extends React.PureComponent {
componentDidMount () {
if (this.props.bindToDocument) {
document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
document.addEventListener('wheel', this.handleWheel, listenerOptions);
} else {
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
this.node.addEventListener('wheel', this.handleWheel, listenerOptions);
}
}
componentWillUnmount () {
if (this.props.bindToDocument) {
document.removeEventListener('wheel', this.handleWheel);
document.removeEventListener('wheel', this.handleWheel, listenerOptions);
} else {
this.node.removeEventListener('wheel', this.handleWheel);
this.node.removeEventListener('wheel', this.handleWheel, listenerOptions);
}
}

View file

@ -1,10 +1,12 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import Icon from 'mastodon/components/icon';
import { PureComponent } from 'react';
import { createPortal } from 'react-dom';
export default class ColumnBackButton extends React.PureComponent {
import { FormattedMessage } from 'react-intl';
import { Icon } from 'mastodon/components/icon';
export default class ColumnBackButton extends PureComponent {
static contextTypes = {
router: PropTypes.object,
@ -12,13 +14,19 @@ export default class ColumnBackButton extends React.PureComponent {
static propTypes = {
multiColumn: PropTypes.bool,
onClick: PropTypes.func,
};
handleClick = () => {
if (window.history && window.history.state) {
this.context.router.history.goBack();
const { router } = this.context;
const { onClick } = this.props;
if (onClick) {
onClick();
} else if (router.history.location?.state?.fromMastodon) {
router.history.goBack();
} else {
this.context.router.history.push('/');
router.history.push('/');
}
};

View file

@ -1,14 +1,15 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Icon } from 'mastodon/components/icon';
import ColumnBackButton from './column_back_button';
import Icon from 'mastodon/components/icon';
export default class ColumnBackButtonSlim extends ColumnBackButton {
render () {
return (
<div className='column-back-button--slim'>
<div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'>
<div role='button' tabIndex={0} onClick={this.handleClick} className='column-back-button column-back-button--slim-button'>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</div>

View file

@ -1,9 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { createPortal } from 'react-dom';
import classNames from 'classnames';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import Icon from 'mastodon/components/icon';
import classNames from 'classnames';
import { Icon } from 'mastodon/components/icon';
const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
@ -12,8 +15,7 @@ const messages = defineMessages({
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
});
export default @injectIntl
class ColumnHeader extends React.PureComponent {
class ColumnHeader extends PureComponent {
static contextTypes = {
router: PropTypes.object,
@ -61,10 +63,12 @@ class ColumnHeader extends React.PureComponent {
};
handleBackClick = () => {
if (window.history && window.history.state) {
this.context.router.history.goBack();
const { router } = this.context;
if (router.history.location?.state?.fromMastodon) {
router.history.goBack();
} else {
this.context.router.history.push('/');
router.history.push('/');
}
};
@ -81,6 +85,7 @@ class ColumnHeader extends React.PureComponent {
};
render () {
const { router } = this.context;
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
const { collapsed, animating } = this.state;
@ -124,7 +129,7 @@ class ColumnHeader extends React.PureComponent {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
}
if (!pinned && (multiColumn || showBackButton)) {
if (!pinned && ((multiColumn && router.history.location?.state?.fromMastodon) || showBackButton)) {
backButton = (
<button onClick={this.handleBackClick} className='column-header__back-button'>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
@ -209,3 +214,5 @@ class ColumnHeader extends React.PureComponent {
}
}
export default injectIntl(ColumnHeader);

View file

@ -1,62 +0,0 @@
// @ts-check
import React from 'react';
import { FormattedMessage } from 'react-intl';
/**
* Returns custom renderer for one of the common counter types
*
* @param {"statuses" | "following" | "followers"} counterType
* Type of the counter
* @param {boolean} isBold Whether display number must be displayed in bold
* @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
* Renderer function
* @throws If counterType is not covered by this function
*/
export function counterRenderer(counterType, isBold = true) {
/**
* @type {(displayNumber: JSX.Element) => JSX.Element}
*/
const renderCounter = isBold
? (displayNumber) => <strong>{displayNumber}</strong>
: (displayNumber) => displayNumber;
switch (counterType) {
case 'statuses': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.statuses_counter'
defaultMessage='{count, plural, one {{counter} Post} other {{counter} Posts}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'following': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.following_counter'
defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'followers': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.followers_counter'
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
}
}

View file

@ -0,0 +1,45 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
export const StatusesCounter = (
displayNumber: React.ReactNode,
pluralReady: number,
) => (
<FormattedMessage
id='account.statuses_counter'
defaultMessage='{count, plural, one {{counter} Post} other {{counter} Posts}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);
export const FollowingCounter = (
displayNumber: React.ReactNode,
pluralReady: number,
) => (
<FormattedMessage
id='account.following_counter'
defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);
export const FollowersCounter = (
displayNumber: React.ReactNode,
pluralReady: number,
) => (
<FormattedMessage
id='account.followers_counter'
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);

View file

@ -1,51 +0,0 @@
import React from 'react';
import IconButton from './icon_button';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import { bannerSettings } from 'mastodon/settings';
const messages = defineMessages({
dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
});
export default @injectIntl
class DismissableBanner extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
children: PropTypes.node,
intl: PropTypes.object.isRequired,
};
state = {
visible: !bannerSettings.get(this.props.id),
};
handleDismiss = () => {
const { id } = this.props;
this.setState({ visible: false }, () => bannerSettings.set(id, true));
};
render () {
const { visible } = this.state;
if (!visible) {
return null;
}
const { children, intl } = this.props;
return (
<div className='dismissable-banner'>
<div className='dismissable-banner__message'>
{children}
</div>
<div className='dismissable-banner__action'>
<IconButton icon='times' title={intl.formatMessage(messages.dismiss)} onClick={this.handleDismiss} />
</div>
</div>
);
}
}

View file

@ -0,0 +1,66 @@
/* eslint-disable @typescript-eslint/no-unsafe-call,
@typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-assignment,
@typescript-eslint/no-unsafe-member-access
-- the settings store is not yet typed */
import type { PropsWithChildren } from 'react';
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeSetting } from 'mastodon/actions/settings';
import { bannerSettings } from 'mastodon/settings';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { IconButton } from './icon_button';
const messages = defineMessages({
dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
});
interface Props {
id: string;
}
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id,
children,
}) => {
const dismissed = useAppSelector((state) =>
state.settings.getIn(['dismissed_banners', id], false),
);
const dispatch = useAppDispatch();
const [visible, setVisible] = useState(!bannerSettings.get(id) && !dismissed);
const intl = useIntl();
const handleDismiss = useCallback(() => {
setVisible(false);
bannerSettings.set(id, true);
dispatch(changeSetting(['dismissed_banners', id], true));
}, [id, dispatch]);
useEffect(() => {
if (!visible && !dismissed) {
dispatch(changeSetting(['dismissed_banners', id], true));
}
}, [id, dispatch, visible, dismissed]);
if (!visible) {
return null;
}
return (
<div className='dismissable-banner'>
<div className='dismissable-banner__action'>
<IconButton
icon='times'
title={intl.formatMessage(messages.dismiss)}
onClick={handleDismiss}
/>
</div>
<div className='dismissable-banner__message'>{children}</div>
</div>
);
};

View file

@ -1,79 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { autoPlayGif } from 'mastodon/initial_state';
import Skeleton from 'mastodon/components/skeleton';
export default class DisplayName extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
others: ImmutablePropTypes.list,
localDomain: PropTypes.string,
};
handleMouseEnter = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original');
}
};
handleMouseLeave = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static');
}
};
render () {
const { others, localDomain } = this.props;
let displayName, suffix, account;
if (others && others.size > 1) {
displayName = others.take(2).map(a => <bdi key={a.get('id')}><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi>).reduce((prev, cur) => [prev, ', ', cur]);
if (others.size - 2 > 0) {
suffix = `+${others.size - 2}`;
}
} else if ((others && others.size > 0) || this.props.account) {
if (others && others.size > 0) {
account = others.first();
} else {
account = this.props.account;
}
let acct = account.get('acct');
if (acct.indexOf('@') === -1 && localDomain) {
acct = `${acct}@${localDomain}`;
}
displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
suffix = <span className='display-name__account'>@{acct}</span>;
} else {
displayName = <bdi><strong className='display-name__html'><Skeleton width='10ch' /></strong></bdi>;
suffix = <span className='display-name__account'><Skeleton width='7ch' /></span>;
}
return (
<span className='display-name' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
{displayName} {suffix}
</span>
);
}
}

View file

@ -0,0 +1,121 @@
import React from 'react';
import type { List } from 'immutable';
import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';
import { Skeleton } from './skeleton';
interface Props {
account?: Account;
others?: List<Account>;
localDomain?: string;
}
export class DisplayName extends React.PureComponent<Props> {
handleMouseEnter: React.ReactEventHandler<HTMLSpanElement> = ({
currentTarget,
}) => {
if (autoPlayGif) {
return;
}
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji');
emojis.forEach((emoji) => {
const originalSrc = emoji.getAttribute('data-original');
if (originalSrc != null) emoji.src = originalSrc;
});
};
handleMouseLeave: React.ReactEventHandler<HTMLSpanElement> = ({
currentTarget,
}) => {
if (autoPlayGif) {
return;
}
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji');
emojis.forEach((emoji) => {
const staticSrc = emoji.getAttribute('data-static');
if (staticSrc != null) emoji.src = staticSrc;
});
};
render() {
const { others, localDomain } = this.props;
let displayName: React.ReactNode,
suffix: React.ReactNode,
account: Account | undefined;
if (others && others.size > 0) {
account = others.first();
} else if (this.props.account) {
account = this.props.account;
}
if (others && others.size > 1) {
displayName = others
.take(2)
.map((a) => (
<bdi key={a.get('id')}>
<strong
className='display-name__html'
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
/>
</bdi>
))
.reduce((prev, cur) => [prev, ', ', cur]);
if (others.size - 2 > 0) {
suffix = `+${others.size - 2}`;
}
} else if (account) {
let acct = account.get('acct');
if (!acct.includes('@') && localDomain) {
acct = `${acct}@${localDomain}`;
}
displayName = (
<bdi>
<strong
className='display-name__html'
dangerouslySetInnerHTML={{
__html: account.get('display_name_html'),
}}
/>
</bdi>
);
suffix = <span className='display-name__account'>@{acct}</span>;
} else {
displayName = (
<bdi>
<strong className='display-name__html'>
<Skeleton width='10ch' />
</strong>
</bdi>
);
suffix = (
<span className='display-name__account'>
<Skeleton width='7ch' />
</span>
);
}
return (
<span
className='display-name'
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
>
{displayName} {suffix}
</span>
);
}
}

View file

@ -1,42 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import IconButton from './icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
});
export default @injectIntl
class Account extends ImmutablePureComponent {
static propTypes = {
domain: PropTypes.string,
onUnblockDomain: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleDomainUnblock = () => {
this.props.onUnblockDomain(this.props.domain);
};
render () {
const { domain, intl } = this.props;
return (
<div className='domain'>
<div className='domain__wrapper'>
<span className='domain__domain-name'>
<strong>{domain}</strong>
</span>
<div className='domain__buttons'>
<IconButton active icon='unlock' title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={this.handleDomainUnblock} />
</div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,44 @@
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { IconButton } from './icon_button';
const messages = defineMessages({
unblockDomain: {
id: 'account.unblock_domain',
defaultMessage: 'Unblock domain {domain}',
},
});
interface Props {
domain: string;
onUnblockDomain: (domain: string) => void;
}
export const Domain: React.FC<Props> = ({ domain, onUnblockDomain }) => {
const intl = useIntl();
const handleDomainUnblock = useCallback(() => {
onUnblockDomain(domain);
}, [domain, onUnblockDomain]);
return (
<div className='domain'>
<div className='domain__wrapper'>
<span className='domain__domain-name'>
<strong>{domain}</strong>
</span>
<div className='domain__buttons'>
<IconButton
active
icon='unlock'
title={intl.formatMessage(messages.unblockDomain, { domain })}
onClick={handleDomainUnblock}
/>
</div>
</div>
</div>
);
};

View file

@ -1,16 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from './icon_button';
import Overlay from 'react-overlays/Overlay';
import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames';
import { CircularProgress } from 'mastodon/components/loading_indicator';
import { PureComponent, cloneElement, Children } from 'react';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
import { CircularProgress } from "./circular_progress";
import { IconButton } from './icon_button';
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
let id = 0;
class DropdownMenu extends React.PureComponent {
class DropdownMenu extends PureComponent {
static contextTypes = {
router: PropTypes.object,
@ -35,12 +39,14 @@ class DropdownMenu extends React.PureComponent {
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
e.stopPropagation();
e.preventDefault();
}
};
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('keydown', this.handleKeyDown, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) {
@ -49,8 +55,8 @@ class DropdownMenu extends React.PureComponent {
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('keydown', this.handleKeyDown, false);
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('keydown', this.handleKeyDown, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
@ -115,11 +121,11 @@ class DropdownMenu extends React.PureComponent {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, href = '#', target = '_blank', method } = option;
const { text, href = '#', target = '_blank', method, dangerous } = option;
return (
<li className='dropdown-menu__item' key={`${text}-${i}`}>
<a href={href} target={target} data-method={method} rel='noopener noreferrer' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
<li className={classNames('dropdown-menu__item', { 'dropdown-menu__item--dangerous': dangerous })} key={`${text}-${i}`}>
<a href={href} target={target} data-method={method} rel='noopener noreferrer' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
{text}
</a>
</li>
@ -154,7 +160,7 @@ class DropdownMenu extends React.PureComponent {
}
export default class Dropdown extends React.PureComponent {
export default class Dropdown extends PureComponent {
static contextTypes = {
router: PropTypes.object,
@ -285,14 +291,14 @@ export default class Dropdown extends React.PureComponent {
const open = this.state.id === openDropdownId;
const button = children ? React.cloneElement(React.Children.only(children), {
const button = children ? cloneElement(Children.only(children), {
onClick: this.handleClick,
onMouseDown: this.handleMouseDown,
onKeyDown: this.handleButtonKeyDown,
onKeyPress: this.handleKeyPress,
}) : (
<IconButton
icon={icon}
icon={!open ? icon : 'close'}
title={title}
active={open}
disabled={disabled}
@ -305,7 +311,7 @@ export default class Dropdown extends React.PureComponent {
);
return (
<React.Fragment>
<>
<span ref={this.setTargetRef}>
{button}
</span>
@ -328,7 +334,7 @@ export default class Dropdown extends React.PureComponent {
</div>
)}
</Overlay>
</React.Fragment>
</>
);
}

View file

@ -1,4 +1,5 @@
import { connect } from 'react-redux';
import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_menu';
import { fetchHistory } from 'mastodon/actions/history';
import DropdownMenu from 'mastodon/components/dropdown_menu';

View file

@ -1,24 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import Icon from 'mastodon/components/icon';
import DropdownMenu from './containers/dropdown_menu_container';
import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal';
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
import { Icon } from 'mastodon/components/icon';
import InlineAccount from 'mastodon/components/inline_account';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import DropdownMenu from './containers/dropdown_menu_container';
const mapDispatchToProps = (dispatch, { statusId }) => ({
onItemClick (index) {
dispatch(openModal('COMPARE_HISTORY', { index, statusId }));
dispatch(openModal({
modalType: 'COMPARE_HISTORY',
modalProps: { index, statusId },
}));
},
});
export default @connect(null, mapDispatchToProps)
@injectIntl
class EditedTimestamp extends React.PureComponent {
class EditedTimestamp extends PureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
@ -34,7 +39,7 @@ class EditedTimestamp extends React.PureComponent {
renderHeader = items => {
return (
<FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {{count} time} other {{count} times}}' values={{ count: items.size - 1 }} />
<FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {# time} other {# times}}' values={{ count: items.size - 1 }} />
);
};
@ -68,3 +73,5 @@ class EditedTimestamp extends React.PureComponent {
}
}
export default connect(null, mapDispatchToProps)(injectIntl(EditedTimestamp));

Some files were not shown because too many files have changed in this diff Show more