Merge remote-tracking branch 'origin/develop' into chats

This commit is contained in:
Alex Gleason 2022-09-19 13:01:40 -05:00
commit d7243c0e91
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
152 changed files with 2995 additions and 3980 deletions

View File

@ -2,6 +2,7 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutabl
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { InstanceRecord } from 'soapbox/normalizers';
import { ReducerCompose } from 'soapbox/reducers/compose';
import { uploadCompose, submitCompose } from '../compose';
import { STATUS_CREATE_REQUEST } from '../statuses';
@ -26,7 +27,8 @@ describe('uploadCompose()', () => {
const state = rootState
.set('me', '1234')
.set('instance', instance);
.set('instance', instance)
.setIn(['compose', 'home'], ReducerCompose());
store = mockStore(state);
files = [{
@ -43,7 +45,7 @@ describe('uploadCompose()', () => {
} as unknown as IntlShape;
const expectedActions = [
{ type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true },
{ type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true },
{
type: 'ALERT_SHOW',
message: 'Image exceeds the current file size limit (10 Bytes)',
@ -51,10 +53,10 @@ describe('uploadCompose()', () => {
actionLink: undefined,
severity: 'error',
},
{ type: 'COMPOSE_UPLOAD_FAIL', error: true, skipLoading: true },
{ type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true },
];
await store.dispatch(uploadCompose(files, mockIntl));
await store.dispatch(uploadCompose('home', files, mockIntl));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
@ -78,7 +80,8 @@ describe('uploadCompose()', () => {
const state = rootState
.set('me', '1234')
.set('instance', instance);
.set('instance', instance)
.setIn(['compose', 'home'], ReducerCompose());
store = mockStore(state);
files = [{
@ -95,7 +98,7 @@ describe('uploadCompose()', () => {
} as unknown as IntlShape;
const expectedActions = [
{ type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true },
{ type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true },
{
type: 'ALERT_SHOW',
message: 'Video exceeds the current file size limit (10 Bytes)',
@ -103,10 +106,10 @@ describe('uploadCompose()', () => {
actionLink: undefined,
severity: 'error',
},
{ type: 'COMPOSE_UPLOAD_FAIL', error: true, skipLoading: true },
{ type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true },
];
await store.dispatch(uploadCompose(files, mockIntl));
await store.dispatch(uploadCompose('home', files, mockIntl));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
@ -118,10 +121,10 @@ describe('submitCompose()', () => {
it('inserts mentions from text', async() => {
const state = rootState
.set('me', '123')
.setIn(['compose', 'text'], '@alex hello @mkljczk@pl.fediverse.pl @gg@汉语/漢語.com alex@alexgleason.me');
.setIn(['compose', 'home'], ReducerCompose({ text: '@alex hello @mkljczk@pl.fediverse.pl @gg@汉语/漢語.com alex@alexgleason.me' }));
const store = mockStore(state);
await store.dispatch(submitCompose());
await store.dispatch(submitCompose('home'));
const actions = store.getActions();
const statusCreateRequest = actions.find(action => action.type === STATUS_CREATE_REQUEST);

View File

@ -121,6 +121,7 @@ describe('deleteStatus()', () => {
version: '0.0.0',
},
withRedraft: true,
id: 'compose-modal',
},
{ type: 'MODAL_OPEN', modalType: 'COMPOSE', modalProps: undefined },
];

View File

@ -1,5 +1,6 @@
import { fetchRelationships } from 'soapbox/actions/accounts';
import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer';
import { filterBadges, getTagDiff } from 'soapbox/utils/badges';
import { getFeatures } from 'soapbox/utils/features';
import api, { getLinks } from '../api';
@ -403,6 +404,12 @@ const tagUsers = (accountIds: string[], tags: string[]) =>
const untagUsers = (accountIds: string[], tags: string[]) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const nicknames = nicknamesFromIds(getState, accountIds);
// Legacy: allow removing legacy 'donor' tags.
if (tags.includes('badge:donor')) {
tags = [...tags, 'donor'];
}
dispatch({ type: ADMIN_USERS_UNTAG_REQUEST, accountIds, tags });
return api(getState)
.delete('/api/v1/pleroma/admin/users/tag', { data: { nicknames, tags } })
@ -413,6 +420,24 @@ const untagUsers = (accountIds: string[], tags: string[]) =>
});
};
/** Synchronizes user tags to the backend. */
const setTags = (accountId: string, oldTags: string[], newTags: string[]) =>
async(dispatch: AppDispatch) => {
const diff = getTagDiff(oldTags, newTags);
await dispatch(tagUsers([accountId], diff.added));
await dispatch(untagUsers([accountId], diff.removed));
};
/** Synchronizes badges to the backend. */
const setBadges = (accountId: string, oldTags: string[], newTags: string[]) =>
(dispatch: AppDispatch) => {
const oldBadges = filterBadges(oldTags);
const newBadges = filterBadges(newTags);
return dispatch(setTags(accountId, oldBadges, newBadges));
};
const verifyUser = (accountId: string) =>
(dispatch: AppDispatch) =>
dispatch(tagUsers([accountId], ['verified']));
@ -421,14 +446,6 @@ const unverifyUser = (accountId: string) =>
(dispatch: AppDispatch) =>
dispatch(untagUsers([accountId], ['verified']));
const setDonor = (accountId: string) =>
(dispatch: AppDispatch) =>
dispatch(tagUsers([accountId], ['donor']));
const removeDonor = (accountId: string) =>
(dispatch: AppDispatch) =>
dispatch(untagUsers([accountId], ['donor']));
const addPermission = (accountIds: string[], permissionGroup: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const nicknames = nicknamesFromIds(getState, accountIds);
@ -476,6 +493,18 @@ const demoteToUser = (accountId: string) =>
dispatch(removePermission([accountId], 'moderator')),
]);
const setRole = (accountId: string, role: 'user' | 'moderator' | 'admin') =>
(dispatch: AppDispatch) => {
switch (role) {
case 'user':
return dispatch(demoteToUser(accountId));
case 'moderator':
return dispatch(promoteToModerator(accountId));
case 'admin':
return dispatch(promoteToAdmin(accountId));
}
};
const suggestUsers = (accountIds: string[]) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const nicknames = nicknamesFromIds(getState, accountIds);
@ -567,15 +596,16 @@ export {
fetchModerationLog,
tagUsers,
untagUsers,
setTags,
setBadges,
verifyUser,
unverifyUser,
setDonor,
removeDonor,
addPermission,
removePermission,
promoteToAdmin,
promoteToModerator,
demoteToUser,
setRole,
suggestUsers,
unsuggestUsers,
};

View File

@ -20,6 +20,7 @@ import KVStore from 'soapbox/storage/kv_store';
import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
import sourceCode from 'soapbox/utils/code';
import { getFeatures } from 'soapbox/utils/features';
import { normalizeUsername } from 'soapbox/utils/input';
import { isStandalone } from 'soapbox/utils/state';
import api, { baseClient } from '../api';
@ -205,16 +206,6 @@ export const loadCredentials = (token: string, accountUrl: string) =>
.then(() => dispatch(verifyCredentials(token, accountUrl)))
.catch(() => dispatch(verifyCredentials(token, accountUrl)));
/** Trim the username and strip the leading @. */
const normalizeUsername = (username: string): string => {
const trimmed = username.trim();
if (trimmed[0] === '@') {
return trimmed.slice(1);
} else {
return trimmed;
}
};
export const logIn = (username: string, password: string) =>
(dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => {
return dispatch(createUserToken(normalizeUsername(username), password));

View File

@ -54,9 +54,6 @@ const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE';
@ -101,14 +98,6 @@ const messages = defineMessages({
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
});
const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
const ensureComposeIsVisible = (getState: () => RootState, routerHistory: History) => {
if (!getState().compose.mounted && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
routerHistory.push('/posts/new');
}
};
const setComposeToStatus = (status: Status, rawText: string, spoilerText?: string, contentType?: string | false, withRedraft?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const { instance } = getState();
@ -116,6 +105,7 @@ const setComposeToStatus = (status: Status, rawText: string, spoilerText?: strin
dispatch({
type: COMPOSE_SET_STATUS,
id: 'compose-modal',
status,
rawText,
explicitAddressing,
@ -126,8 +116,9 @@ const setComposeToStatus = (status: Status, rawText: string, spoilerText?: strin
});
};
const changeCompose = (text: string) => ({
const changeCompose = (composeId: string, text: string) => ({
type: COMPOSE_CHANGE,
id: composeId,
text: text,
});
@ -139,6 +130,7 @@ const replyCompose = (status: Status) =>
dispatch({
type: COMPOSE_REPLY,
id: 'compose-modal',
status: status,
account: state.accounts.get(state.me),
explicitAddressing,
@ -147,22 +139,9 @@ const replyCompose = (status: Status) =>
dispatch(openModal('COMPOSE'));
};
const replyComposeWithConfirmation = (status: Status, intl: IntlShape) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
if (state.compose.text.trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(status)),
}));
} else {
dispatch(replyCompose(status));
}
};
const cancelReplyCompose = () => ({
type: COMPOSE_REPLY_CANCEL,
id: 'compose-modal',
});
const quoteCompose = (status: Status) =>
@ -173,6 +152,7 @@ const quoteCompose = (status: Status) =>
dispatch({
type: COMPOSE_QUOTE,
id: 'compose-modal',
status: status,
account: state.accounts.get(state.me),
explicitAddressing,
@ -183,16 +163,19 @@ const quoteCompose = (status: Status) =>
const cancelQuoteCompose = () => ({
type: COMPOSE_QUOTE_CANCEL,
id: 'compose-modal',
});
const resetCompose = () => ({
const resetCompose = (composeId = 'compose-modal') => ({
type: COMPOSE_RESET,
id: composeId,
});
const mentionCompose = (account: Account) =>
(dispatch: AppDispatch) => {
dispatch({
type: COMPOSE_MENTION,
id: 'compose-modal',
account: account,
});
@ -203,6 +186,7 @@ const directCompose = (account: Account) =>
(dispatch: AppDispatch) => {
dispatch({
type: COMPOSE_DIRECT,
id: 'compose-modal',
account: account,
});
@ -215,22 +199,23 @@ const directComposeById = (accountId: string) =>
dispatch({
type: COMPOSE_DIRECT,
id: 'compose-modal',
account: account,
});
dispatch(openModal('COMPOSE'));
};
const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, data: APIEntity, status: string, edit?: boolean) => {
const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, composeId: string, data: APIEntity, status: string, edit?: boolean) => {
if (!dispatch || !getState) return;
dispatch(insertIntoTagHistory(data.tags || [], status));
dispatch(submitComposeSuccess({ ...data }));
dispatch(insertIntoTagHistory(composeId, data.tags || [], status));
dispatch(submitComposeSuccess(composeId, { ...data }));
dispatch(snackbar.success(edit ? messages.editSuccess : messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`));
};
const needsDescriptions = (state: RootState) => {
const media = state.compose.media_attachments;
const needsDescriptions = (state: RootState, composeId: string) => {
const media = state.compose.get(composeId)!.media_attachments;
const missingDescriptionModal = getSettings(state).get('missingDescriptionModal');
const hasMissing = media.filter(item => !item.description).size > 0;
@ -238,8 +223,8 @@ const needsDescriptions = (state: RootState) => {
return missingDescriptionModal && hasMissing;
};
const validateSchedule = (state: RootState) => {
const schedule = state.compose.schedule;
const validateSchedule = (state: RootState, composeId: string) => {
const schedule = state.compose.get(composeId)?.schedule;
if (!schedule) return true;
const fiveMinutesFromNow = new Date(new Date().getTime() + 300000);
@ -247,17 +232,19 @@ const validateSchedule = (state: RootState) => {
return schedule.getTime() > fiveMinutesFromNow.getTime();
};
const submitCompose = (routerHistory?: History, force = false) =>
const submitCompose = (composeId: string, routerHistory?: History, force = false) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const status = state.compose.text;
const media = state.compose.media_attachments;
const statusId = state.compose.id;
let to = state.compose.to;
const compose = state.compose.get(composeId)!;
if (!validateSchedule(state)) {
const status = compose.text;
const media = compose.media_attachments;
const statusId = compose.id;
let to = compose.to;
if (!validateSchedule(state, composeId)) {
dispatch(snackbar.error(messages.scheduleError));
return;
}
@ -266,11 +253,11 @@ const submitCompose = (routerHistory?: History, force = false) =>
return;
}
if (!force && needsDescriptions(state)) {
if (!force && needsDescriptions(state, composeId)) {
dispatch(openModal('MISSING_DESCRIPTION', {
onContinue: () => {
dispatch(closeModal('MISSING_DESCRIPTION'));
dispatch(submitCompose(routerHistory, true));
dispatch(submitCompose(composeId, routerHistory, true));
},
}));
return;
@ -282,22 +269,22 @@ const submitCompose = (routerHistory?: History, force = false) =>
to = to.union(mentions.map(mention => mention.trim().slice(1)));
}
dispatch(submitComposeRequest());
dispatch(submitComposeRequest(composeId));
dispatch(closeModal());
const idempotencyKey = state.compose.idempotencyKey;
const idempotencyKey = compose.idempotencyKey;
const params = {
status,
in_reply_to_id: state.compose.in_reply_to,
quote_id: state.compose.quote,
in_reply_to_id: compose.in_reply_to,
quote_id: compose.quote,
media_ids: media.map(item => item.id),
sensitive: state.compose.sensitive,
spoiler_text: state.compose.spoiler_text,
visibility: state.compose.privacy,
content_type: state.compose.content_type,
poll: state.compose.poll,
scheduled_at: state.compose.schedule,
sensitive: compose.sensitive,
spoiler_text: compose.spoiler_text,
visibility: compose.privacy,
content_type: compose.content_type,
poll: compose.poll,
scheduled_at: compose.schedule,
to,
};
@ -305,27 +292,30 @@ const submitCompose = (routerHistory?: History, force = false) =>
if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && routerHistory) {
routerHistory.push('/messages');
}
handleComposeSubmit(dispatch, getState, data, status, !!statusId);
handleComposeSubmit(dispatch, getState, composeId, data, status, !!statusId);
}).catch(function(error) {
dispatch(submitComposeFail(error));
dispatch(submitComposeFail(composeId, error));
});
};
const submitComposeRequest = () => ({
const submitComposeRequest = (composeId: string) => ({
type: COMPOSE_SUBMIT_REQUEST,
id: composeId,
});
const submitComposeSuccess = (status: APIEntity) => ({
const submitComposeSuccess = (composeId: string, status: APIEntity) => ({
type: COMPOSE_SUBMIT_SUCCESS,
id: composeId,
status: status,
});
const submitComposeFail = (error: AxiosError) => ({
const submitComposeFail = (composeId: string, error: AxiosError) => ({
type: COMPOSE_SUBMIT_FAIL,
id: composeId,
error: error,
});
const uploadCompose = (files: FileList, intl: IntlShape) =>
const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const attachmentLimit = getState().instance.configuration.getIn(['statuses', 'max_media_attachments']) as number;
@ -333,19 +323,21 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined;
const maxVideoDuration = getState().instance.configuration.getIn(['media_attachments', 'video_duration_limit']) as number | undefined;
const media = getState().compose.media_attachments;
const media = getState().compose.get(composeId)?.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 > attachmentLimit) {
const mediaCount = media ? media.size : 0;
if (files.length + mediaCount > attachmentLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit, 'error'));
return;
}
dispatch(uploadComposeRequest());
dispatch(uploadComposeRequest(composeId));
Array.from(files).forEach(async(f, i) => {
if (media.size + i > attachmentLimit - 1) return;
if (mediaCount + i > attachmentLimit - 1) return;
const isImage = f.type.match(/image.*/);
const isVideo = f.type.match(/video.*/);
@ -355,18 +347,18 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
const limit = formatBytes(maxImageSize);
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
dispatch(snackbar.error(message));
dispatch(uploadComposeFail(true));
dispatch(uploadComposeFail(composeId, true));
return;
} else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) {
const limit = formatBytes(maxVideoSize);
const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit });
dispatch(snackbar.error(message));
dispatch(uploadComposeFail(true));
dispatch(uploadComposeFail(composeId, true));
return;
} else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) {
const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration });
dispatch(snackbar.error(message));
dispatch(uploadComposeFail(true));
dispatch(uploadComposeFail(composeId, true));
return;
}
@ -380,7 +372,7 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
const onUploadProgress = ({ loaded }: any) => {
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
dispatch(uploadComposeProgress(composeId, progress.reduce((a, v) => a + v, 0), total));
};
return dispatch(uploadMedia(data, onUploadProgress))
@ -388,98 +380,107 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
// 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));
dispatch(uploadComposeSuccess(composeId, data, f));
} else if (status === 202) {
const poll = () => {
dispatch(fetchMedia(data.id)).then(({ status, data }) => {
if (status === 200) {
dispatch(uploadComposeSuccess(data, f));
dispatch(uploadComposeSuccess(composeId, data, f));
} else if (status === 206) {
setTimeout(() => poll(), 1000);
}
}).catch(error => dispatch(uploadComposeFail(error)));
}).catch(error => dispatch(uploadComposeFail(composeId, error)));
};
poll();
}
});
}).catch(error => dispatch(uploadComposeFail(error)));
}).catch(error => dispatch(uploadComposeFail(composeId, error)));
/* eslint-enable no-loop-func */
});
};
const changeUploadCompose = (id: string, params: Record<string, any>) =>
const changeUploadCompose = (composeId: string, id: string, params: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(changeUploadComposeRequest());
dispatch(changeUploadComposeRequest(composeId));
dispatch(updateMedia(id, params)).then(response => {
dispatch(changeUploadComposeSuccess(response.data));
dispatch(changeUploadComposeSuccess(composeId, response.data));
}).catch(error => {
dispatch(changeUploadComposeFail(id, error));
dispatch(changeUploadComposeFail(composeId, id, error));
});
};
const changeUploadComposeRequest = () => ({
const changeUploadComposeRequest = (composeId: string) => ({
type: COMPOSE_UPLOAD_CHANGE_REQUEST,
id: composeId,
skipLoading: true,
});
const changeUploadComposeSuccess = (media: APIEntity) => ({
const changeUploadComposeSuccess = (composeId: string, media: APIEntity) => ({
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
id: composeId,
media: media,
skipLoading: true,
});
const changeUploadComposeFail = (id: string, error: AxiosError) => ({
const changeUploadComposeFail = (composeId: string, id: string, error: AxiosError) => ({
type: COMPOSE_UPLOAD_CHANGE_FAIL,
composeId,
id,
error: error,
skipLoading: true,
});
const uploadComposeRequest = () => ({
const uploadComposeRequest = (composeId: string) => ({
type: COMPOSE_UPLOAD_REQUEST,
id: composeId,
skipLoading: true,
});
const uploadComposeProgress = (loaded: number, total: number) => ({
const uploadComposeProgress = (composeId: string, loaded: number, total: number) => ({
type: COMPOSE_UPLOAD_PROGRESS,
id: composeId,
loaded: loaded,
total: total,
});
const uploadComposeSuccess = (media: APIEntity, file: File) => ({
const uploadComposeSuccess = (composeId: string, media: APIEntity, file: File) => ({
type: COMPOSE_UPLOAD_SUCCESS,
id: composeId,
media: media,
file,
skipLoading: true,
});
const uploadComposeFail = (error: AxiosError | true) => ({
const uploadComposeFail = (composeId: string, error: AxiosError | true) => ({
type: COMPOSE_UPLOAD_FAIL,
id: composeId,
error: error,
skipLoading: true,
});
const undoUploadCompose = (media_id: string) => ({
const undoUploadCompose = (composeId: string, media_id: string) => ({
type: COMPOSE_UPLOAD_UNDO,
id: composeId,
media_id: media_id,
});
const clearComposeSuggestions = () => {
const clearComposeSuggestions = (composeId: string) => {
if (cancelFetchComposeSuggestionsAccounts) {
cancelFetchComposeSuggestionsAccounts();
}
return {
type: COMPOSE_SUGGESTIONS_CLEAR,
id: composeId,
};
};
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => {
if (cancelFetchComposeSuggestionsAccounts) {
cancelFetchComposeSuggestionsAccounts();
cancelFetchComposeSuggestionsAccounts(composeId);
}
api(getState).get('/api/v1/accounts/search', {
cancelToken: new CancelToken(cancel => {
@ -492,7 +493,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) =>
},
}).then(response => {
dispatch(importFetchedAccounts(response.data));
dispatch(readyComposeSuggestionsAccounts(token, response.data));
dispatch(readyComposeSuggestionsAccounts(composeId, token, response.data));
}).catch(error => {
if (!isCancel(error)) {
dispatch(showAlertForError(error));
@ -500,46 +501,48 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) =>
});
}, 200, { leading: true, trailing: true });
const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, token: string) => {
const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any);
dispatch(readyComposeSuggestionsEmojis(token, results));
dispatch(readyComposeSuggestionsEmojis(composeId, token, results));
};
const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, token: string) => {
const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
const state = getState();
const currentTrends = state.trends.items;
dispatch(updateSuggestionTags(token, currentTrends));
dispatch(updateSuggestionTags(composeId, token, currentTrends));
};
const fetchComposeSuggestions = (token: string) =>
const fetchComposeSuggestions = (composeId: string, token: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
switch (token[0]) {
case ':':
fetchComposeSuggestionsEmojis(dispatch, getState, token);
fetchComposeSuggestionsEmojis(dispatch, getState, composeId, token);
break;
case '#':
fetchComposeSuggestionsTags(dispatch, getState, token);
fetchComposeSuggestionsTags(dispatch, getState, composeId, token);
break;
default:
fetchComposeSuggestionsAccounts(dispatch, getState, token);
fetchComposeSuggestionsAccounts(dispatch, getState, composeId, token);
break;
}
};
const readyComposeSuggestionsEmojis = (token: string, emojis: Emoji[]) => ({
const readyComposeSuggestionsEmojis = (composeId: string, token: string, emojis: Emoji[]) => ({
type: COMPOSE_SUGGESTIONS_READY,
id: composeId,
token,
emojis,
});
const readyComposeSuggestionsAccounts = (token: string, accounts: APIEntity[]) => ({
const readyComposeSuggestionsAccounts = (composeId: string, token: string, accounts: APIEntity[]) => ({
type: COMPOSE_SUGGESTIONS_READY,
id: composeId,
token,
accounts,
});
const selectComposeSuggestion = (position: number, token: string | null, suggestion: AutoSuggestion, path: Array<string | number>) =>
const selectComposeSuggestion = (composeId: string, position: number, token: string | null, suggestion: AutoSuggestion, path: Array<string | number>) =>
(dispatch: AppDispatch, getState: () => RootState) => {
let completion, startPosition;
@ -558,6 +561,7 @@ const selectComposeSuggestion = (position: number, token: string | null, suggest
dispatch({
type: COMPOSE_SUGGESTION_SELECT,
id: composeId,
position: startPosition,
token,
completion,
@ -565,21 +569,23 @@ const selectComposeSuggestion = (position: number, token: string | null, suggest
});
};
const updateSuggestionTags = (token: string, currentTrends: ImmutableList<Tag>) => ({
const updateSuggestionTags = (composeId: string, token: string, currentTrends: ImmutableList<Tag>) => ({
type: COMPOSE_SUGGESTION_TAGS_UPDATE,
id: composeId,
token,
currentTrends,
});
const updateTagHistory = (tags: string[]) => ({
const updateTagHistory = (composeId: string, tags: string[]) => ({
type: COMPOSE_TAG_HISTORY_UPDATE,
id: composeId,
tags,
});
const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) =>
const insertIntoTagHistory = (composeId: string, recognizedTags: APIEntity[], text: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const oldHistory = state.compose.tagHistory;
const oldHistory = state.compose.get(composeId)!.tagHistory;
const me = state.me;
const names = recognizedTags
.filter(tag => text.match(new RegExp(`#${tag.name}`, 'i')))
@ -591,120 +597,124 @@ const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) =>
const newHistory = names.slice(0, 1000);
tagHistory.set(me as string, newHistory);
dispatch(updateTagHistory(newHistory));
dispatch(updateTagHistory(composeId, newHistory));
};
const mountCompose = () => ({
type: COMPOSE_MOUNT,
});
const unmountCompose = () => ({
type: COMPOSE_UNMOUNT,
});
const changeComposeSensitivity = () => ({
const changeComposeSensitivity = (composeId: string) => ({
type: COMPOSE_SENSITIVITY_CHANGE,
id: composeId,
});
const changeComposeSpoilerness = () => ({
const changeComposeSpoilerness = (composeId: string) => ({
type: COMPOSE_SPOILERNESS_CHANGE,
id: composeId,
});
const changeComposeContentType = (value: string) => ({
const changeComposeContentType = (composeId: string, value: string) => ({
type: COMPOSE_TYPE_CHANGE,
id: composeId,
value,
});
const changeComposeSpoilerText = (text: string) => ({
const changeComposeSpoilerText = (composeId: string, text: string) => ({
type: COMPOSE_SPOILER_TEXT_CHANGE,
id: composeId,
text,
});
const changeComposeVisibility = (value: string) => ({
const changeComposeVisibility = (composeId: string, value: string) => ({
type: COMPOSE_VISIBILITY_CHANGE,
id: composeId,
value,
});
const insertEmojiCompose = (position: number, emoji: string, needsSpace: boolean) => ({
const insertEmojiCompose = (composeId: string, position: number, emoji: Emoji, needsSpace: boolean) => ({
type: COMPOSE_EMOJI_INSERT,
id: composeId,
position,
emoji,
needsSpace,
});
const changeComposing = (value: string) => ({
type: COMPOSE_COMPOSING_CHANGE,
value,
});
const addPoll = () => ({
const addPoll = (composeId: string) => ({
type: COMPOSE_POLL_ADD,
id: composeId,
});
const removePoll = () => ({
const removePoll = (composeId: string) => ({
type: COMPOSE_POLL_REMOVE,
id: composeId,
});
const addSchedule = () => ({
const addSchedule = (composeId: string) => ({
type: COMPOSE_SCHEDULE_ADD,
id: composeId,
});
const setSchedule = (date: Date) => ({
const setSchedule = (composeId: string, date: Date) => ({
type: COMPOSE_SCHEDULE_SET,
id: composeId,
date: date,
});
const removeSchedule = () => ({
const removeSchedule = (composeId: string) => ({
type: COMPOSE_SCHEDULE_REMOVE,
id: composeId,
});
const addPollOption = (title: string) => ({
const addPollOption = (composeId: string, title: string) => ({
type: COMPOSE_POLL_OPTION_ADD,
id: composeId,
title,
});
const changePollOption = (index: number, title: string) => ({
const changePollOption = (composeId: string, index: number, title: string) => ({
type: COMPOSE_POLL_OPTION_CHANGE,
id: composeId,
index,
title,
});
const removePollOption = (index: number) => ({
const removePollOption = (composeId: string, index: number) => ({
type: COMPOSE_POLL_OPTION_REMOVE,
id: composeId,
index,
});
const changePollSettings = (expiresIn?: string | number, isMultiple?: boolean) => ({
const changePollSettings = (composeId: string, expiresIn?: string | number, isMultiple?: boolean) => ({
type: COMPOSE_POLL_SETTINGS_CHANGE,
id: composeId,
expiresIn,
isMultiple,
});
const openComposeWithText = (text = '') =>
const openComposeWithText = (composeId: string, text = '') =>
(dispatch: AppDispatch) => {
dispatch(resetCompose());
dispatch(resetCompose(composeId));
dispatch(openModal('COMPOSE'));
dispatch(changeCompose(text));
dispatch(changeCompose(composeId, text));
};
const addToMentions = (accountId: string) =>
const addToMentions = (composeId: string, accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const acct = state.accounts.get(accountId)!.acct;
return dispatch({
type: COMPOSE_ADD_TO_MENTIONS,
id: composeId,
account: acct,
});
};
const removeFromMentions = (accountId: string) =>
const removeFromMentions = (composeId: string, accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const acct = state.accounts.get(accountId)!.acct;
return dispatch({
type: COMPOSE_REMOVE_FROM_MENTIONS,
id: composeId,
account: acct,
});
};
@ -731,8 +741,6 @@ export {
COMPOSE_SUGGESTION_SELECT,
COMPOSE_SUGGESTION_TAGS_UPDATE,
COMPOSE_TAG_HISTORY_UPDATE,
COMPOSE_MOUNT,
COMPOSE_UNMOUNT,
COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_TYPE_CHANGE,
@ -756,11 +764,9 @@ export {
COMPOSE_ADD_TO_MENTIONS,
COMPOSE_REMOVE_FROM_MENTIONS,
COMPOSE_SET_STATUS,
ensureComposeIsVisible,
setComposeToStatus,
changeCompose,
replyCompose,
replyComposeWithConfirmation,
cancelReplyCompose,
quoteCompose,
cancelQuoteCompose,
@ -790,15 +796,12 @@ export {
selectComposeSuggestion,
updateSuggestionTags,
updateTagHistory,
mountCompose,
unmountCompose,
changeComposeSensitivity,
changeComposeSpoilerness,
changeComposeContentType,
changeComposeSpoilerText,
changeComposeVisibility,
insertEmojiCompose,
changeComposing,
addPoll,
removePoll,
addSchedule,

View File

@ -177,7 +177,6 @@ const toggleFavourite = (status: StatusEntity) =>
}
};
const favouriteRequest = (status: StatusEntity) => ({
type: FAVOURITE_REQUEST,
status: status,

View File

@ -5,6 +5,8 @@ import { fetchAccountByUsername } from 'soapbox/actions/accounts';
import { deactivateUsers, deleteUsers, deleteStatus, toggleStatusSensitivity } from 'soapbox/actions/admin';
import { openModal } from 'soapbox/actions/modals';
import snackbar from 'soapbox/actions/snackbar';
import OutlineBox from 'soapbox/components/outline-box';
import { Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { isLocal } from 'soapbox/utils/accounts';
@ -43,10 +45,22 @@ const deactivateUserModal = (intl: IntlShape, accountId: string, afterConfirm =
const acct = state.accounts.get(accountId)!.acct;
const name = state.accounts.get(accountId)!.username;
const message = (
<Stack space={4}>
<OutlineBox>
<AccountContainer id={accountId} />
</OutlineBox>
<Text>
{intl.formatMessage(messages.deactivateUserPrompt, { acct })}
</Text>
</Stack>
);
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/user-off.svg'),
heading: intl.formatMessage(messages.deactivateUserHeading, { acct }),
message: intl.formatMessage(messages.deactivateUserPrompt, { acct }),
message,
confirm: intl.formatMessage(messages.deactivateUserConfirm, { name }),
onConfirm: () => {
dispatch(deactivateUsers([accountId])).then(() => {
@ -64,22 +78,21 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () =
const account = state.accounts.get(accountId)!;
const acct = account.acct;
const name = account.username;
const favicon = account.pleroma.get('favicon');
const local = isLocal(account);
const message = (<>
<AccountContainer id={accountId} />
{intl.formatMessage(messages.deleteUserPrompt, { acct })}
</>);
const message = (
<Stack space={4}>
<OutlineBox>
<AccountContainer id={accountId} />
</OutlineBox>
const confirm = (<>
{favicon &&
<div className='submit__favicon'>
<img src={favicon} alt='' />
</div>}
{intl.formatMessage(messages.deleteUserConfirm, { name })}
</>);
<Text>
{intl.formatMessage(messages.deleteUserPrompt, { acct })}
</Text>
</Stack>
);
const confirm = intl.formatMessage(messages.deleteUserConfirm, { name });
const checkbox = local ? intl.formatMessage(messages.deleteLocalUserCheckbox) : false;
dispatch(openModal('CONFIRM', {

View File

@ -8,9 +8,10 @@ import type { SearchFilter } from 'soapbox/reducers/search';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
const SEARCH_CHANGE = 'SEARCH_CHANGE';
const SEARCH_CLEAR = 'SEARCH_CLEAR';
const SEARCH_SHOW = 'SEARCH_SHOW';
const SEARCH_CHANGE = 'SEARCH_CHANGE';
const SEARCH_CLEAR = 'SEARCH_CLEAR';
const SEARCH_SHOW = 'SEARCH_SHOW';
const SEARCH_RESULTS_CLEAR = 'SEARCH_RESULTS_CLEAR';
const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
@ -28,7 +29,11 @@ const changeSearch = (value: string) =>
(dispatch: AppDispatch) => {
// If backspaced all the way, clear the search
if (value.length === 0) {
return dispatch(clearSearch());
dispatch(clearSearchResults());
return dispatch({
type: SEARCH_CHANGE,
value,
});
} else {
return dispatch({
type: SEARCH_CHANGE,
@ -41,6 +46,10 @@ const clearSearch = () => ({
type: SEARCH_CLEAR,
});
const clearSearchResults = () => ({
type: SEARCH_RESULTS_CLEAR,
});
const submitSearch = (filter?: SearchFilter) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const value = getState().search.value;
@ -167,6 +176,7 @@ export {
SEARCH_CHANGE,
SEARCH_CLEAR,
SEARCH_SHOW,
SEARCH_RESULTS_CLEAR,
SEARCH_FETCH_REQUEST,
SEARCH_FETCH_SUCCESS,
SEARCH_FETCH_FAIL,
@ -177,6 +187,7 @@ export {
SEARCH_ACCOUNT_SET,
changeSearch,
clearSearch,
clearSearchResults,
submitSearch,
fetchSearchRequest,
fetchSearchSuccess,

View File

@ -7,6 +7,7 @@
import snackbar from 'soapbox/actions/snackbar';
import { getLoggedInAccount } from 'soapbox/utils/auth';
import { parseVersion, TRUTHSOCIAL } from 'soapbox/utils/features';
import { normalizeUsername } from 'soapbox/utils/input';
import api from '../api';
@ -84,15 +85,16 @@ const changePassword = (oldPassword: string, newPassword: string, confirmation:
const resetPassword = (usernameOrEmail: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const input = normalizeUsername(usernameOrEmail);
const state = getState();
const v = parseVersion(state.instance.version);
dispatch({ type: RESET_PASSWORD_REQUEST });
const params =
usernameOrEmail.includes('@')
? { email: usernameOrEmail }
: { nickname: usernameOrEmail, username: usernameOrEmail };
input.includes('@')
? { email: input }
: { nickname: input, username: input };
const endpoint =
v.software === TRUTHSOCIAL

View File

@ -3,7 +3,7 @@ import React from 'react';
import { TransitionMotion, spring } from 'react-motion';
import { Icon } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/compose/containers/emoji_picker_dropdown_container';
import EmojiPickerDropdown from 'soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown';
import { useSettings } from 'soapbox/hooks';
import Reaction from './reaction';

View File

@ -46,8 +46,8 @@ interface IAutosuggesteTextarea {
onSuggestionsClearRequested: () => void,
onSuggestionsFetchRequested: (token: string | number) => void,
onChange: React.ChangeEventHandler<HTMLTextAreaElement>,
onKeyUp: React.KeyboardEventHandler<HTMLTextAreaElement>,
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement>,
onKeyUp?: React.KeyboardEventHandler<HTMLTextAreaElement>,
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>,
onPaste: (files: FileList) => void,
autoFocus: boolean,
onFocus: () => void,

View File

@ -3,24 +3,27 @@ import React from 'react';
interface IBadge {
title: React.ReactNode,
slug: 'patron' | 'donor' | 'admin' | 'moderator' | 'bot' | 'opaque',
slug: string,
}
/** Badge to display on a user's profile. */
const Badge: React.FC<IBadge> = ({ title, slug }) => (
<span
data-testid='badge'
className={classNames('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', {
'bg-fuchsia-700 text-white': slug === 'patron',
'bg-yellow-500 text-white': slug === 'donor',
'bg-black text-white': slug === 'admin',
'bg-cyan-600 text-white': slug === 'moderator',
'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100': slug === 'bot',
'bg-white bg-opacity-75 text-gray-900': slug === 'opaque',
})}
>
{title}
</span>
);
const Badge: React.FC<IBadge> = ({ title, slug }) => {
const fallback = !['patron', 'admin', 'moderator', 'opaque', 'badge:donor'].includes(slug);
return (
<span
data-testid='badge'
className={classNames('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', {
'bg-fuchsia-700 text-white': slug === 'patron',
'bg-emerald-800 text-white': slug === 'badge:donor',
'bg-black text-white': slug === 'admin',
'bg-cyan-600 text-white': slug === 'moderator',
'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100': fallback,
'bg-white bg-opacity-75 text-gray-900': slug === 'opaque',
})}
>
{title}
</span>
);
};
export default Badge;

View File

@ -34,7 +34,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
id: domId,
className: classNames({
'w-auto': isSelect,
}),
}, child.props.className),
});
}

View File

@ -15,7 +15,7 @@ const messages = defineMessages({
});
export const checkComposeContent = compose => {
return [
return !!compose && [
compose.text.length > 0,
compose.spoiler_text.length > 0,
compose.media_attachments.size > 0,
@ -24,8 +24,8 @@ export const checkComposeContent = compose => {
};
const mapStateToProps = state => ({
hasComposeContent: checkComposeContent(state.compose),
isEditing: state.compose.id !== null,
hasComposeContent: checkComposeContent(state.compose.get('compose-modal')),
isEditing: state.compose.get('compose-modal')?.id !== null,
});
const mapDispatchToProps = (dispatch) => ({

View File

@ -0,0 +1,21 @@
import classNames from 'clsx';
import React from 'react';
interface IOutlineBox extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode,
className?: string,
}
/** Wraps children in a container with an outline. */
const OutlineBox: React.FC<IOutlineBox> = ({ children, className, ...rest }) => {
return (
<div
className={classNames('p-4 rounded-lg border border-solid border-gray-300 dark:border-gray-800', className)}
{...rest}
>
{children}
</div>
);
};
export default OutlineBox;

View File

@ -45,7 +45,6 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
}
return (
<Stack space={4} data-testid='poll-footer'>
{(!showResults && poll?.multiple) && (

View File

@ -37,10 +37,6 @@ const getBadges = (account: Account): JSX.Element[] => {
badges.push(<Badge key='patron' slug='patron' title='Patron' />);
}
if (account.donor) {
badges.push(<Badge key='donor' slug='donor' title='Donor' />);
}
return badges;
};

View File

@ -9,6 +9,8 @@ import AccountContainer from 'soapbox/containers/account_container';
import { useSettings } from 'soapbox/hooks';
import { defaultMediaVisibility } from 'soapbox/utils/status';
import OutlineBox from './outline-box';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
const messages = defineMessages({
@ -123,38 +125,41 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
}
return (
<Stack
<OutlineBox
data-testid='quoted-status'
space={2}
className={classNames('mt-3 p-4 rounded-lg border border-solid border-gray-300 dark:border-gray-800 cursor-pointer', {
className={classNames('mt-3 cursor-pointer', {
'hover:bg-gray-100 dark:hover:bg-gray-800': !compose,
})}
onClick={handleExpandClick}
>
<AccountContainer
{...actions}
id={account.id}
timestamp={status.created_at}
withRelationship={false}
showProfileHoverCard={!compose}
withLinkToProfile={!compose}
/>
<Stack
space={2}
onClick={handleExpandClick}
>
<AccountContainer
{...actions}
id={account.id}
timestamp={status.created_at}
withRelationship={false}
showProfileHoverCard={!compose}
withLinkToProfile={!compose}
/>
{renderReplyMentions()}
{renderReplyMentions()}
<Text
className='break-words status__content status__content--quote'
size='sm'
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
/>
<Text
className='break-words status__content status__content--quote'
size='sm'
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
/>
<StatusMedia
status={status}
muted={compose}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
</Stack>
<StatusMedia
status={status}
muted={compose}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
</Stack>
</OutlineBox>
);
};

View File

@ -37,6 +37,7 @@ const messages = defineMessages({
invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' },
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
addAccount: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' },
followRequests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
});
interface ISidebarLink {
@ -87,6 +88,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
const otherAccounts: ImmutableList<AccountEntity> = useAppSelector((state) => getOtherAccounts(state));
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
const settings = useAppSelector((state) => getSettings(state));
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
const closeButtonRef = React.useRef(null);
@ -177,6 +179,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
onClick={onClose}
/>
{(account.locked || followRequestsCount > 0) && (
<SidebarLink
to='/follow_requests'
icon={require('@tabler/icons/user-plus.svg')}
text={intl.formatMessage(messages.followRequests)}
onClick={onClose}
/>
)}
{features.bookmarks && (
<SidebarLink
to='/bookmarks'

View File

@ -10,7 +10,7 @@ import { launchChat } from 'soapbox/actions/chats';
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { deactivateUserModal, deleteStatusModal, deleteUserModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport } from 'soapbox/actions/reports';
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
@ -51,7 +51,7 @@ const messages = defineMessages({
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
embed: { id: 'status.embed', defaultMessage: 'Embed' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' },
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' },
@ -123,18 +123,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const handleReplyClick: React.MouseEventHandler = (e) => {
if (me) {
dispatch((_, getState) => {
const state = getState();
if (state.compose.text.trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(status)),
}));
} else {
dispatch(replyCompose(status));
}
});
dispatch(replyCompose(status));
} else {
onOpenUnauthorizedModal('REPLY');
}
@ -186,18 +175,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
e.stopPropagation();
if (me) {
dispatch((_, getState) => {
const state = getState();
if (state.compose.text.trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(quoteCompose(status)),
}));
} else {
dispatch(quoteCompose(status));
}
});
dispatch(quoteCompose(status));
} else {
onOpenUnauthorizedModal('REBLOG');
}
@ -321,14 +299,10 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
}
};
const handleDeactivateUser: React.EventHandler<React.MouseEvent> = (e) => {
const onModerate: React.MouseEventHandler = (e) => {
e.stopPropagation();
dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']) as string));
};
const handleDeleteUser: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(deleteUserModal(intl, status.getIn(['account', 'id']) as string));
const account = status.account as Account;
dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id }));
};
const handleDeleteStatus: React.EventHandler<React.MouseEvent> = (e) => {
@ -474,13 +448,13 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
if (isStaff) {
menu.push(null);
menu.push({
text: intl.formatMessage(messages.adminAccount, { name: username }),
action: onModerate,
icon: require('@tabler/icons/gavel.svg'),
});
if (isAdmin) {
menu.push({
text: intl.formatMessage(messages.admin_account, { name: username }),
href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`,
icon: require('@tabler/icons/gavel.svg'),
action: (event) => event.stopPropagation(),
});
menu.push({
text: intl.formatMessage(messages.admin_status),
href: `/pleroma/admin/#/statuses/${status.id}/`,
@ -496,17 +470,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
});
if (!ownAccount) {
menu.push({
text: intl.formatMessage(messages.deactivateUser, { name: username }),
action: handleDeactivateUser,
icon: require('@tabler/icons/user-off.svg'),
});
menu.push({
text: intl.formatMessage(messages.deleteUser, { name: username }),
action: handleDeleteUser,
icon: require('@tabler/icons/user-minus.svg'),
destructive: true,
});
menu.push({
text: intl.formatMessage(messages.deleteStatus),
action: handleDeleteStatus,

View File

@ -4,7 +4,7 @@ import { HotKeys } from 'react-hotkeys';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { NavLink, useHistory } from 'react-router-dom';
import { mentionCompose, replyComposeWithConfirmation } from 'soapbox/actions/compose';
import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { toggleStatusHidden } from 'soapbox/actions/statuses';
@ -125,7 +125,7 @@ const Status: React.FC<IStatus> = (props) => {
const handleHotkeyReply = (e?: KeyboardEvent): void => {
e?.preventDefault();
dispatch(replyComposeWithConfirmation(actualStatus, intl));
dispatch(replyCompose(actualStatus));
};
const handleHotkeyFavourite = (): void => {

View File

@ -1,7 +1,9 @@
import classNames from 'clsx';
import { Map as ImmutableMap } from 'immutable';
import debounce from 'lodash/debounce';
import React, { useRef, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { v4 as uuidv4 } from 'uuid';
import LoadGap from 'soapbox/components/load_gap';
import ScrollableList from 'soapbox/components/scrollable_list';
@ -9,6 +11,7 @@ import StatusContainer from 'soapbox/containers/status_container';
import Ad from 'soapbox/features/ads/components/ad';
import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
import { ALGORITHMS } from 'soapbox/features/timeline-insertion';
import PendingStatus from 'soapbox/features/ui/components/pending_status';
import { useSoapboxConfig } from 'soapbox/hooks';
import useAds from 'soapbox/queries/ads';
@ -60,8 +63,12 @@ const StatusList: React.FC<IStatusList> = ({
}) => {
const { data: ads } = useAds();
const soapboxConfig = useSoapboxConfig();
const adsInterval = Number(soapboxConfig.extensions.getIn(['ads', 'interval'], 40)) || 0;
const adsAlgorithm = String(soapboxConfig.extensions.getIn(['ads', 'algorithm', 0]));
const adsOpts = (soapboxConfig.extensions.getIn(['ads', 'algorithm', 1], ImmutableMap()) as ImmutableMap<string, any>).toJS();
const node = useRef<VirtuosoHandle>(null);
const seed = useRef<string>(uuidv4());
const getFeaturedStatusCount = () => {
return featuredStatusIds?.size || 0;
@ -132,9 +139,10 @@ const StatusList: React.FC<IStatusList> = ({
);
};
const renderAd = (ad: AdEntity) => {
const renderAd = (ad: AdEntity, index: number) => {
return (
<Ad
key={`ad-${index}`}
card={ad.card}
impression={ad.impression}
expires={ad.expires}
@ -175,9 +183,13 @@ const StatusList: React.FC<IStatusList> = ({
const renderStatuses = (): React.ReactNode[] => {
if (isLoading || statusIds.size > 0) {
return statusIds.toList().reduce((acc, statusId, index) => {
const adIndex = ads ? Math.floor((index + 1) / adsInterval) % ads.length : 0;
const ad = ads ? ads[adIndex] : undefined;
const showAd = (index + 1) % adsInterval === 0;
if (showAds && ads) {
const ad = ALGORITHMS[adsAlgorithm]?.(ads, index, { ...adsOpts, seed: seed.current });
if (ad) {
acc.push(renderAd(ad, index));
}
}
if (statusId === null) {
acc.push(renderLoadGap(index));
@ -189,10 +201,6 @@ const StatusList: React.FC<IStatusList> = ({
acc.push(renderStatus(statusId));
}
if (showAds && ad && showAd) {
acc.push(renderAd(ad));
}
return acc;
}, [] as React.ReactNode[]);
} else {

View File

@ -34,6 +34,7 @@ export { default as Select } from './select/select';
export { default as Spinner } from './spinner/spinner';
export { default as Stack } from './stack/stack';
export { default as Tabs } from './tabs/tabs';
export { default as TagInput } from './tag-input/tag-input';
export { default as Text } from './text/text';
export { default as Textarea } from './textarea/textarea';
export { default as Toggle } from './toggle/toggle';

View File

@ -36,6 +36,7 @@ interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxL
prepend?: React.ReactElement,
/** An element to display as suffix to input. Cannot be used with password type. */
append?: React.ReactElement,
/** Adds specific styling to denote a searchabe input. */
isSearch?: boolean,
}

View File

@ -39,7 +39,7 @@ interface IStack extends React.HTMLAttributes<HTMLDivElement> {
}
/** Vertical stack of child elements. */
const Stack: React.FC<IStack> = React.forwardRef((props, ref: React.LegacyRef<HTMLDivElement> | undefined) => {
const Stack = React.forwardRef<HTMLDivElement, IStack>((props, ref: React.LegacyRef<HTMLDivElement> | undefined) => {
const { space, alignItems, justifyContent, className, grow, ...filteredProps } = props;
return (

View File

@ -0,0 +1,70 @@
import React, { useState } from 'react';
import HStack from '../hstack/hstack';
import Tag from './tag';
interface ITagInput {
tags: string[],
onChange: (tags: string[]) => void,
placeholder?: string,
}
/** Manage a list of tags. */
// https://blog.logrocket.com/building-a-tag-input-field-component-for-react/
const TagInput: React.FC<ITagInput> = ({ tags, onChange, placeholder }) => {
const [input, setInput] = useState('');
const handleTagDelete = (tag: string) => {
onChange(tags.filter(item => item !== tag));
};
const handleKeyDown: React.KeyboardEventHandler = (e) => {
const { key } = e;
const trimmedInput = input.trim();
if (key === 'Tab') {
e.preventDefault();
}
if ([',', 'Tab', 'Enter'].includes(key) && trimmedInput.length && !tags.includes(trimmedInput)) {
e.preventDefault();
onChange([...tags, trimmedInput]);
setInput('');
}
if (key === 'Backspace' && !input.length && tags.length) {
e.preventDefault();
const tagsCopy = [...tags];
tagsCopy.pop();
onChange(tagsCopy);
}
};
return (
<div className='mt-1 relative shadow-sm flex-grow'>
<HStack
className='p-2 pb-0 text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500 rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800'
space={2}
wrap
>
{tags.map((tag, i) => (
<div className='mb-2'>
<Tag tag={tag} onDelete={handleTagDelete} />
</div>
))}
<input
className='p-1 mb-2 w-32 h-8 flex-grow bg-transparent outline-none'
value={input}
placeholder={placeholder}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
/>
</HStack>
</div>
);
};
export default TagInput;

View File

@ -0,0 +1,29 @@
import React from 'react';
import IconButton from '../icon-button/icon-button';
import Text from '../text/text';
interface ITag {
/** Name of the tag. */
tag: string,
/** Callback when the X icon is pressed. */
onDelete: (tag: string) => void,
}
/** A single editable Tag (used by TagInput). */
const Tag: React.FC<ITag> = ({ tag, onDelete }) => {
return (
<div className='inline-flex p-1 rounded bg-primary-500 items-center whitespace-nowrap'>
<Text theme='white'>{tag}</Text>
<IconButton
iconClassName='w-4 h-4'
src={require('@tabler/icons/x.svg')}
onClick={() => onDelete(tag)}
transparent
/>
</div>
);
};
export default Tag;

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import Status, { IStatus } from 'soapbox/components/status';
import { useAppSelector } from 'soapbox/hooks';
@ -16,14 +16,14 @@ interface IStatusContainer extends Omit<IStatus, 'status'> {
updateScrollBottom?: any,
}
const getStatus = makeGetStatus();
/**
* Legacy Status wrapper accepting a status ID instead of the full entity.
* @deprecated Use the Status component directly.
*/
const StatusContainer: React.FC<IStatusContainer> = (props) => {
const { id, ...rest } = props;
const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector(state => getStatus(state, { id }));
if (status) {

View File

@ -6,12 +6,10 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { blockAccount, followAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts';
import { verifyUser, unverifyUser, setDonor, removeDonor, promoteToAdmin, promoteToModerator, demoteToUser, suggestUsers, unsuggestUsers } from 'soapbox/actions/admin';
import { launchChat } from 'soapbox/actions/chats';
import { mentionCompose, directCompose } from 'soapbox/actions/compose';
import { blockDomain, unblockDomain } from 'soapbox/actions/domain_blocks';
import { openModal } from 'soapbox/actions/modals';
import { deactivateUserModal } from 'soapbox/actions/moderation';
import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport } from 'soapbox/actions/reports';
import { setSearchAccount } from 'soapbox/actions/search';
@ -26,10 +24,7 @@ import ActionButton from 'soapbox/features/ui/components/action-button';
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { Account } from 'soapbox/types/entities';
import {
isLocal,
isRemote,
} from 'soapbox/utils/accounts';
import { isRemote } from 'soapbox/utils/accounts';
import type { Menu as MenuType } from 'soapbox/components/dropdown_menu';
@ -59,39 +54,17 @@ const messages = defineMessages({
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
removeFromFollowers: { id: 'account.remove_from_followers', defaultMessage: 'Remove this follower' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' },
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' },
deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' },
verifyUser: { id: 'admin.users.actions.verify_user', defaultMessage: 'Verify @{name}' },
unverifyUser: { id: 'admin.users.actions.unverify_user', defaultMessage: 'Unverify @{name}' },
setDonor: { id: 'admin.users.actions.set_donor', defaultMessage: 'Set @{name} as a donor' },
removeDonor: { id: 'admin.users.actions.remove_donor', defaultMessage: 'Remove @{name} as a donor' },
promoteToAdmin: { id: 'admin.users.actions.promote_to_admin', defaultMessage: 'Promote @{name} to an admin' },
promoteToModerator: { id: 'admin.users.actions.promote_to_moderator', defaultMessage: 'Promote @{name} to a moderator' },
demoteToModerator: { id: 'admin.users.actions.demote_to_moderator', defaultMessage: 'Demote @{name} to a moderator' },
demoteToUser: { id: 'admin.users.actions.demote_to_user', defaultMessage: 'Demote @{name} to a regular user' },
suggestUser: { id: 'admin.users.actions.suggest_user', defaultMessage: 'Suggest @{name}' },
unsuggestUser: { id: 'admin.users.actions.unsuggest_user', defaultMessage: 'Unsuggest @{name}' },
search: { id: 'account.search', defaultMessage: 'Search from @{name}' },
searchSelf: { id: 'account.search_self', defaultMessage: 'Search your posts' },
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
userVerified: { id: 'admin.users.user_verified_message', defaultMessage: '@{acct} was verified' },
userUnverified: { id: 'admin.users.user_unverified_message', defaultMessage: '@{acct} was unverified' },
setDonorSuccess: { id: 'admin.users.set_donor_message', defaultMessage: '@{acct} was set as a donor' },
removeDonorSuccess: { id: 'admin.users.remove_donor_message', defaultMessage: '@{acct} was removed as a donor' },
promotedToAdmin: { id: 'admin.users.actions.promote_to_admin_message', defaultMessage: '@{acct} was promoted to an admin' },
promotedToModerator: { id: 'admin.users.actions.promote_to_moderator_message', defaultMessage: '@{acct} was promoted to a moderator' },
demotedToModerator: { id: 'admin.users.actions.demote_to_moderator_message', defaultMessage: '@{acct} was demoted to a moderator' },
demotedToUser: { id: 'admin.users.actions.demote_to_user_message', defaultMessage: '@{acct} was demoted to a regular user' },
userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' },
userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' },
removeFromFollowersConfirm: { id: 'confirmations.remove_from_followers.confirm', defaultMessage: 'Remove' },
userEndorsed: { id: 'account.endorse.success', defaultMessage: 'You are now featuring @{acct} on your profile' },
userUnendorsed: { id: 'account.unendorse.success', defaultMessage: 'You are no longer featuring @{acct}' },
});
interface IHeader {
@ -209,81 +182,8 @@ const Header: React.FC<IHeader> = ({ account }) => {
dispatch(launchChat(account.id, history));
};
const onDeactivateUser = () => {
dispatch(deactivateUserModal(intl, account.id));
};
const onVerifyUser = () => {
const message = intl.formatMessage(messages.userVerified, { acct: account.acct });
dispatch(verifyUser(account.id))
.then(() => dispatch(snackbar.success(message)))
.catch(() => { });
};
const onUnverifyUser = () => {
const message = intl.formatMessage(messages.userUnverified, { acct: account.acct });
dispatch(unverifyUser(account.id))
.then(() => dispatch(snackbar.success(message)))
.catch(() => { });
};
const onSetDonor = () => {
const message = intl.formatMessage(messages.setDonorSuccess, { acct: account.acct });
dispatch(setDonor(account.id))
.then(() => dispatch(snackbar.success(message)))
.catch(() => { });
};
const onRemoveDonor = () => {
const message = intl.formatMessage(messages.removeDonorSuccess, { acct: account.acct });
dispatch(removeDonor(account.id))
.then(() => dispatch(snackbar.success(message)))
.catch(() => { });
};
const onPromoteToAdmin = () => {
const message = intl.formatMessage(messages.promotedToAdmin, { acct: account.acct });
dispatch(promoteToAdmin(account.id))
.then(() => dispatch(snackbar.success(message)))
.catch(() => { });
};
const onPromoteToModerator = () => {
const messageType = account.admin ? messages.demotedToModerator : messages.promotedToModerator;
const message = intl.formatMessage(messageType, { acct: account.acct });
dispatch(promoteToModerator(account.id))
.then(() => dispatch(snackbar.success(message)))
.catch(() => { });
};
const onDemoteToUser = () => {
const message = intl.formatMessage(messages.demotedToUser, { acct: account.acct });
dispatch(demoteToUser(account.id))
.then(() => dispatch(snackbar.success(message)))
.catch(() => { });
};
const onSuggestUser = () => {
const message = intl.formatMessage(messages.userSuggested, { acct: account.acct });
dispatch(suggestUsers([account.id]))
.then(() => dispatch(snackbar.success(message)))
.catch(() => { });
};
const onUnsuggestUser = () => {
const message = intl.formatMessage(messages.userUnsuggested, { acct: account.acct });
dispatch(unsuggestUsers([account.id]))
.then(() => dispatch(snackbar.success(message)))
.catch(() => { });
const onModerate = () => {
dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id }));
};
const onRemoveFromFollowers = () => {
@ -378,6 +278,13 @@ const Header: React.FC<IHeader> = ({ account }) => {
to: '/settings',
icon: require('@tabler/icons/settings.svg'),
});
if (features.searchFromAccount) {
menu.push({
text: intl.formatMessage(messages.searchSelf, { name: account.username }),
action: onSearch,
icon: require('@tabler/icons/search.svg'),
});
}
menu.push(null);
menu.push({
text: intl.formatMessage(messages.mutes),
@ -524,107 +431,11 @@ const Header: React.FC<IHeader> = ({ account }) => {
if (ownAccount?.staff) {
menu.push(null);
if (ownAccount?.admin) {
menu.push({
text: intl.formatMessage(messages.admin_account, { name: account.username }),
to: `/pleroma/admin/#/users/${account.id}/`,
newTab: true,
icon: require('@tabler/icons/gavel.svg'),
});
}
if (account.id !== ownAccount?.id && isLocal(account) && ownAccount.admin) {
if (account.admin) {
menu.push({
text: intl.formatMessage(messages.demoteToModerator, { name: account.username }),
action: onPromoteToModerator,
icon: require('@tabler/icons/arrow-up-circle.svg'),
});
menu.push({
text: intl.formatMessage(messages.demoteToUser, { name: account.username }),
action: onDemoteToUser,
icon: require('@tabler/icons/arrow-down-circle.svg'),
});
} else if (account.moderator) {
menu.push({
text: intl.formatMessage(messages.promoteToAdmin, { name: account.username }),
action: onPromoteToAdmin,
icon: require('@tabler/icons/arrow-up-circle.svg'),
});
menu.push({
text: intl.formatMessage(messages.demoteToUser, { name: account.username }),
action: onDemoteToUser,
icon: require('@tabler/icons/arrow-down-circle.svg'),
});
} else {
menu.push({
text: intl.formatMessage(messages.promoteToAdmin, { name: account.username }),
action: onPromoteToAdmin,
icon: require('@tabler/icons/arrow-up-circle.svg'),
});
menu.push({
text: intl.formatMessage(messages.promoteToModerator, { name: account.username }),
action: onPromoteToModerator,
icon: require('@tabler/icons/arrow-up-circle.svg'),
});
}
}
if (account.verified) {
menu.push({
text: intl.formatMessage(messages.unverifyUser, { name: account.username }),
action: onUnverifyUser,
icon: require('@tabler/icons/check.svg'),
});
} else {
menu.push({
text: intl.formatMessage(messages.verifyUser, { name: account.username }),
action: onVerifyUser,
icon: require('@tabler/icons/check.svg'),
});
}
if (account.donor) {
menu.push({
text: intl.formatMessage(messages.removeDonor, { name: account.username }),
action: onRemoveDonor,
icon: require('@tabler/icons/coin.svg'),
});
} else {
menu.push({
text: intl.formatMessage(messages.setDonor, { name: account.username }),
action: onSetDonor,
icon: require('@tabler/icons/coin.svg'),
});
}
if (features.suggestionsV2 && ownAccount.admin) {
if (account.getIn(['pleroma', 'is_suggested'])) {
menu.push({
text: intl.formatMessage(messages.unsuggestUser, { name: account.username }),
action: onUnsuggestUser,
icon: require('@tabler/icons/user-x.svg'),
});
} else {
menu.push({
text: intl.formatMessage(messages.suggestUser, { name: account.username }),
action: onSuggestUser,
icon: require('@tabler/icons/user-check.svg'),
});
}
}
if (account.id !== ownAccount?.id) {
menu.push({
text: intl.formatMessage(messages.deactivateUser, { name: account.username }),
action: onDeactivateUser,
icon: require('@tabler/icons/user-off.svg'),
});
menu.push({
text: intl.formatMessage(messages.deleteUser, { name: account.username }),
icon: require('@tabler/icons/user-minus.svg'),
});
}
menu.push({
text: intl.formatMessage(messages.adminAccount, { name: account.username }),
action: onModerate,
icon: require('@tabler/icons/gavel.svg'),
});
}
return menu;

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
@ -10,7 +10,8 @@ import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
import { Button, HStack } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
import Accordion from 'soapbox/features/ui/components/accordion';
import { useAppDispatch } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetReport } from 'soapbox/selectors';
import ReportStatus from './report_status';
@ -24,15 +25,21 @@ const messages = defineMessages({
});
interface IReport {
report: AdminReport;
id: string;
}
const Report: React.FC<IReport> = ({ report }) => {
const Report: React.FC<IReport> = ({ id }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const getReport = useCallback(makeGetReport(), []);
const report = useAppSelector((state) => getReport(state, id) as AdminReport | undefined);
const [accordionExpanded, setAccordionExpanded] = useState(false);
if (!report) return null;
const account = report.account as Account;
const targetAccount = report.target_account as Account;

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { approveUsers } from 'soapbox/actions/admin';
@ -13,8 +13,6 @@ const messages = defineMessages({
rejected: { id: 'admin.awaiting_approval.rejected_message', defaultMessage: '{acct} was rejected.' },
});
const getAccount = makeGetAccount();
interface IUnapprovedAccount {
accountId: string,
}
@ -23,6 +21,7 @@ interface IUnapprovedAccount {
const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector(state => getAccount(state, accountId));
const adminAccount = useAppSelector(state => state.admin.users.get(accountId));

View File

@ -4,7 +4,6 @@ import { defineMessages, useIntl } from 'react-intl';
import { fetchReports } from 'soapbox/actions/admin';
import ScrollableList from 'soapbox/components/scrollable_list';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetReport } from 'soapbox/selectors';
import Report from '../components/report';
@ -14,18 +13,13 @@ const messages = defineMessages({
emptyMessage: { id: 'admin.reports.empty_message', defaultMessage: 'There are no open reports. If a user gets reported, they will show up here.' },
});
const getReport = makeGetReport();
const Reports: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [isLoading, setLoading] = useState(true);
const reports = useAppSelector(state => {
const ids = state.admin.openReports;
return ids.toList().map(id => getReport(state, id));
});
const reports = useAppSelector(state => state.admin.openReports.toList());
useEffect(() => {
dispatch(fetchReports())
@ -42,7 +36,7 @@ const Reports: React.FC = () => {
scrollKey='admin-reports'
emptyMessage={intl.formatMessage(messages.emptyMessage)}
>
{reports.map(report => report && <Report report={report} key={report?.id} />)}
{reports.map(report => report && <Report id={report} key={report} />)}
</ScrollableList>
);
};

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { addToAliases } from 'soapbox/actions/aliases';
@ -15,8 +15,6 @@ const messages = defineMessages({
add: { id: 'aliases.account.add', defaultMessage: 'Create alias' },
});
const getAccount = makeGetAccount();
interface IAccount {
accountId: string,
aliases: ImmutableList<string>
@ -25,6 +23,8 @@ interface IAccount {
const Account: React.FC<IAccount> = ({ accountId, aliases }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, accountId));
const added = useAppSelector((state) => {

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Avatar from 'soapbox/components/avatar';
@ -12,14 +12,14 @@ const messages = defineMessages({
birthday: { id: 'account.birthday', defaultMessage: 'Born {date}' },
});
const getAccount = makeGetAccount();
interface IAccount {
accountId: string,
}
const Account: React.FC<IAccount> = ({ accountId }) => {
const intl = useIntl();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, accountId));
// useEffect(() => {
@ -30,7 +30,7 @@ const Account: React.FC<IAccount> = ({ accountId }) => {
if (!account) return null;
const birthday = account.get('birthday');
const birthday = account.birthday;
if (!birthday) return null;
const formattedBirthday = intl.formatDate(birthday, { day: 'numeric', month: 'short', year: 'numeric' });
@ -38,7 +38,7 @@ const Account: React.FC<IAccount> = ({ accountId }) => {
return (
<div className='account'>
<div className='account__wrapper'>
<Permalink className='account__display-name' title={account.get('acct')} href={`/@${account.get('acct')}`} to={`/@${account.get('acct')}`}>
<Permalink className='account__display-name' title={account.acct} href={`/@${account.acct}`} to={`/@${account.acct}`}>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import Account from 'soapbox/components/account';
import { useAppSelector } from 'soapbox/hooks';
@ -9,7 +9,7 @@ interface IAutosuggestAccount {
}
const AutosuggestAccount: React.FC<IAutosuggestAccount> = ({ id }) => {
const getAccount = makeGetAccount();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, id));
if (!account) return null;

View File

@ -0,0 +1,367 @@
import classNames from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { length } from 'stringz';
import {
changeCompose,
submitCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
changeComposeSpoilerText,
insertEmojiCompose,
uploadCompose,
} from 'soapbox/actions/compose';
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest_input';
import AutosuggestTextarea from 'soapbox/components/autosuggest_textarea';
import Icon from 'soapbox/components/icon';
import { Button, Stack } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
import { isMobile } from 'soapbox/is_mobile';
import EmojiPickerDropdown from '../components/emoji-picker/emoji-picker-dropdown';
import MarkdownButton from '../components/markdown_button';
import PollButton from '../components/poll_button';
import PollForm from '../components/polls/poll-form';
import PrivacyDropdown from '../components/privacy_dropdown';
import ReplyMentions from '../components/reply_mentions';
import ScheduleButton from '../components/schedule_button';
import SpoilerButton from '../components/spoiler_button';
import UploadForm from '../components/upload_form';
import Warning from '../components/warning';
import QuotedStatusContainer from '../containers/quoted_status_container';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import ScheduleFormContainer from '../containers/schedule_form_container';
import UploadButtonContainer from '../containers/upload_button_container';
import WarningContainer from '../containers/warning_container';
import { countableText } from '../util/counter';
import TextCharacterCounter from './text_character_counter';
import VisualCharacterCounter from './visual_character_counter';
import type { Emoji } from 'soapbox/components/autosuggest_emoji';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic...' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
message: { id: 'compose_form.message', defaultMessage: 'Message' },
schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
});
interface IComposeForm<ID extends string> {
id: ID extends 'default' ? never : ID,
shouldCondense?: boolean,
autoFocus?: boolean,
clickableAreaRef?: React.RefObject<HTMLDivElement>,
}
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef }: IComposeForm<ID>) => {
const history = useHistory();
const intl = useIntl();
const dispatch = useAppDispatch();
const compose = useCompose(id);
const showSearch = useAppSelector((state) => state.search.submitted && !state.search.hidden);
const isModalOpen = useAppSelector((state) => !!(state.modals.size && state.modals.last()!.modalType === 'COMPOSE'));
const maxTootChars = useAppSelector((state) => state.instance.getIn(['configuration', 'statuses', 'max_characters'])) as number;
const scheduledStatusCount = useAppSelector((state) => state.get('scheduled_statuses').size);
const features = useFeatures();
const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt } = compose;
const hasPoll = !!compose.poll;
const isEditing = compose.id !== null;
const anyMedia = compose.media_attachments.size > 0;
const [composeFocused, setComposeFocused] = useState(false);
const formRef = useRef(null);
const spoilerTextRef = useRef<AutosuggestInput>(null);
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
dispatch(changeCompose(id, e.target.value));
};
const handleKeyDown: React.KeyboardEventHandler = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
handleSubmit();
e.preventDefault(); // Prevent bubbling to other ComposeForm instances
}
};
const getClickableArea = () => {
return clickableAreaRef ? clickableAreaRef.current : formRef.current;
};
const isEmpty = () => {
return !(text || spoilerText || anyMedia);
};
const isClickOutside = (e: MouseEvent | React.MouseEvent) => {
return ![
// List of elements that shouldn't collapse the composer when clicked
// FIXME: Make this less brittle
getClickableArea(),
document.querySelector('.privacy-dropdown__dropdown'),
document.querySelector('.emoji-picker-dropdown__menu'),
document.getElementById('modal-overlay'),
].some(element => element?.contains(e.target as any));
};
const handleClick = useCallback((e: MouseEvent | React.MouseEvent) => {
if (isEmpty() && isClickOutside(e)) {
handleClickOutside();
}
}, []);
const handleClickOutside = () => {
setComposeFocused(false);
};
const handleComposeFocus = () => {
setComposeFocused(true);
};
const handleSubmit = () => {
if (text !== autosuggestTextareaRef.current?.textarea?.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
dispatch(changeCompose(id, autosuggestTextareaRef.current!.textarea!.value));
}
// Submit disabled:
const fulltext = [spoilerText, countableText(text)].join('');
if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxTootChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
return;
}
dispatch(submitCompose(id, history));
};
const onSuggestionsClearRequested = () => {
dispatch(clearComposeSuggestions(id));
};
const onSuggestionsFetchRequested = (token: string | number) => {
dispatch(fetchComposeSuggestions(id, token as string));
};
const onSuggestionSelected = (tokenStart: number, token: string | null, value: string | undefined) => {
if (value) dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['text']));
};
const onSpoilerSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => {
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text']));
};
const handleChangeSpoilerText: React.ChangeEventHandler<HTMLInputElement> = (e) => {
dispatch(changeComposeSpoilerText(id, e.target.value));
};
const setCursor = (start: number, end: number = start) => {
if (!autosuggestTextareaRef.current?.textarea) return;
autosuggestTextareaRef.current.textarea.setSelectionRange(start, end);
};
const handleEmojiPick = (data: Emoji) => {
const position = autosuggestTextareaRef.current!.textarea!.selectionStart;
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
dispatch(insertEmojiCompose(id, position, data, needsSpace));
};
const onPaste = (files: FileList) => {
dispatch(uploadCompose(id, files, intl));
};
const focusSpoilerInput = () => {
spoilerTextRef.current?.input?.focus();
};
const focusTextarea = () => {
autosuggestTextareaRef.current?.textarea?.focus();
};
useEffect(() => {
const length = text.length;
document.addEventListener('click', handleClick, true);
if (length > 0) {
setCursor(length); // Set cursor at end
}
return () => {
document.removeEventListener('click', handleClick, true);
};
}, []);
useEffect(() => {
switch (spoiler) {
case true: focusSpoilerInput(); break;
case false: focusTextarea(); break;
}
}, [spoiler]);
useEffect(() => {
if (typeof caretPosition === 'number') {
setCursor(caretPosition);
}
}, [focusDate]);
const renderButtons = useCallback(() => (
<div className='flex items-center space-x-2'>
{features.media && <UploadButtonContainer composeId={id} />}
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
{features.polls && <PollButton composeId={id} />}
{features.privacyScopes && <PrivacyDropdown composeId={id} />}
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
{features.spoilers && <SpoilerButton composeId={id} />}
{features.richText && <MarkdownButton composeId={id} />}
</div>
), [features, id]);
const condensed = shouldCondense && !composeFocused && isEmpty() && !isUploading;
const disabled = isSubmitting;
const countedText = [spoilerText, countableText(text)].join('');
const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia);
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
let publishText: string | JSX.Element = '';
if (isEditing) {
publishText = intl.formatMessage(messages.saveChanges);
} else if (privacy === 'direct') {
publishText = (
<>
<Icon src={require('@tabler/icons/mail.svg')} />
{intl.formatMessage(messages.message)}
</>
);
} else if (privacy === 'private') {
publishText = (
<>
<Icon src={require('@tabler/icons/lock.svg')} />
{intl.formatMessage(messages.publish)}
</>
);
} else {
publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
if (scheduledAt) {
publishText = intl.formatMessage(messages.schedule);
}
return (
<Stack className='w-full' space={1} ref={formRef} onClick={handleClick}>
{scheduledStatusCount > 0 && (
<Warning
message={(
<FormattedMessage
id='compose_form.scheduled_statuses.message'
defaultMessage='You have scheduled posts. {click_here} to see them.'
values={{ click_here: (
<Link to='/scheduled_statuses'>
<FormattedMessage
id='compose_form.scheduled_statuses.click_here'
defaultMessage='Click here'
/>
</Link>
) }}
/>)
}
/>
)}
<WarningContainer composeId={id} />
{!shouldCondense && <ReplyIndicatorContainer composeId={id} />}
{!shouldCondense && <ReplyMentions composeId={id} />}
<div
className={classNames({
'relative transition-height': true,
'hidden': !spoiler,
})}
>
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={spoilerText}
onChange={handleChangeSpoilerText}
onKeyDown={handleKeyDown}
disabled={!spoiler}
ref={spoilerTextRef}
suggestions={suggestions}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='border-none shadow-none px-0 py-2 text-base'
autoFocus
/>
</div>
<AutosuggestTextarea
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
placeholder={intl.formatMessage(hasPoll ? messages.pollPlaceholder : messages.placeholder)}
disabled={disabled}
value={text}
onChange={handleChange}
suggestions={suggestions}
onKeyDown={handleKeyDown}
onFocus={handleComposeFocus}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSuggestionSelected}
onPaste={onPaste}
autoFocus={shouldAutoFocus}
condensed={condensed}
id='compose-textarea'
>
{
!condensed &&
<div className='compose-form__modifiers'>
<UploadForm composeId={id} />
<PollForm composeId={id} />
<ScheduleFormContainer composeId={id} />
</div>
}
</AutosuggestTextarea>
<QuotedStatusContainer composeId={id} />
<div
className={classNames('flex flex-wrap items-center justify-between', {
'hidden': condensed,
})}
>
{renderButtons()}
<div className='flex items-center space-x-4 ml-auto'>
{maxTootChars && (
<div className='flex items-center space-x-1'>
<TextCharacterCounter max={maxTootChars} text={text} />
<VisualCharacterCounter max={maxTootChars} text={text} />
</div>
)}
<Button theme='primary' text={publishText} onClick={handleSubmit} disabled={disabledButton} />
</div>
</div>
</Stack>
);
};
export default ComposeForm;

View File

@ -1,402 +0,0 @@
import classNames from 'clsx';
import get from 'lodash/get';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage } from 'react-intl';
import { Link, withRouter } from 'react-router-dom';
import { length } from 'stringz';
import AutosuggestInput from 'soapbox/components/autosuggest_input';
import AutosuggestTextarea from 'soapbox/components/autosuggest_textarea';
import Icon from 'soapbox/components/icon';
import { Button, Stack } from 'soapbox/components/ui';
import { isMobile } from 'soapbox/is_mobile';
import PollForm from '../components/polls/poll-form';
import ReplyMentions from '../components/reply_mentions';
import UploadForm from '../components/upload_form';
import Warning from '../components/warning';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import MarkdownButtonContainer from '../containers/markdown_button_container';
import PollButtonContainer from '../containers/poll_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import QuotedStatusContainer from '../containers/quoted_status_container';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import ScheduleButtonContainer from '../containers/schedule_button_container';
import ScheduleFormContainer from '../containers/schedule_form_container';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import UploadButtonContainer from '../containers/upload_button_container';
import WarningContainer from '../containers/warning_container';
import { countableText } from '../util/counter';
import TextCharacterCounter from './text_character_counter';
import VisualCharacterCounter from './visual_character_counter';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic...' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
message: { id: 'compose_form.message', defaultMessage: 'Message' },
schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
});
export default @withRouter
class ComposeForm extends ImmutablePureComponent {
state = {
composeFocused: false,
}
static propTypes = {
intl: PropTypes.object.isRequired,
text: PropTypes.string.isRequired,
suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool,
privacy: PropTypes.string,
spoilerText: PropTypes.string,
focusDate: PropTypes.instanceOf(Date),
caretPosition: PropTypes.number,
hasPoll: PropTypes.bool,
isSubmitting: PropTypes.bool,
isChangingUpload: PropTypes.bool,
isEditing: PropTypes.bool,
isUploading: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
onChangeSpoilerText: PropTypes.func.isRequired,
onPaste: PropTypes.func.isRequired,
onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool,
anyMedia: PropTypes.bool,
shouldCondense: PropTypes.bool,
autoFocus: PropTypes.bool,
group: ImmutablePropTypes.map,
isModalOpen: PropTypes.bool,
clickableAreaRef: PropTypes.object,
scheduledAt: PropTypes.instanceOf(Date),
features: PropTypes.object.isRequired,
};
static defaultProps = {
showSearch: false,
};
handleChange = (e) => {
this.props.onChange(e.target.value);
}
handleComposeFocus = () => {
this.setState({
composeFocused: true,
});
}
handleKeyDown = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
e.preventDefault(); // Prevent bubbling to other ComposeForm instances
}
}
getClickableArea = () => {
const { clickableAreaRef } = this.props;
return clickableAreaRef ? clickableAreaRef.current : this.form;
}
isEmpty = () => {
const { text, spoilerText, anyMedia } = this.props;
return !(text || spoilerText || anyMedia);
}
isClickOutside = (e) => {
return ![
// List of elements that shouldn't collapse the composer when clicked
// FIXME: Make this less brittle
this.getClickableArea(),
document.querySelector('.privacy-dropdown__dropdown'),
document.querySelector('.emoji-picker-dropdown__menu'),
document.getElementById('modal-overlay'),
].some(element => element?.contains(e.target));
}
handleClick = (e) => {
if (this.isEmpty() && this.isClickOutside(e)) {
this.handleClickOutside();
}
}
handleClickOutside = () => {
this.setState({
composeFocused: false,
});
}
handleSubmit = () => {
if (this.props.text !== this.autosuggestTextarea.textarea.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
this.props.onChange(this.autosuggestTextarea.textarea.value);
}
// Submit disabled:
const { isSubmitting, isChangingUpload, isUploading, anyMedia, maxTootChars } = this.props;
const fulltext = [this.props.spoilerText, countableText(this.props.text)].join('');
if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxTootChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
return;
}
this.props.onSubmit(this.props.history ? this.props.history : null, this.props.group);
}
onSuggestionsClearRequested = () => {
this.props.onClearSuggestions();
}
onSuggestionsFetchRequested = (token) => {
this.props.onFetchSuggestions(token);
}
onSuggestionSelected = (tokenStart, token, value) => {
this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
}
onSpoilerSuggestionSelected = (tokenStart, token, value) => {
this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
}
handleChangeSpoilerText = (e) => {
this.props.onChangeSpoilerText(e.target.value);
}
setCursor = (start, end = start) => {
if (!this.autosuggestTextarea) return;
this.autosuggestTextarea.textarea.setSelectionRange(start, end);
}
componentDidMount() {
const length = this.props.text.length;
document.addEventListener('click', this.handleClick, true);
if (length > 0) {
this.setCursor(length); // Set cursor at end
}
}
componentWillUnmount() {
document.removeEventListener('click', this.handleClick, true);
}
setAutosuggestTextarea = (c) => {
this.autosuggestTextarea = c;
}
setForm = (c) => {
this.form = c;
}
setSpoilerText = (c) => {
this.spoilerText = c;
}
handleEmojiPick = (data) => {
const { text } = this.props;
const position = this.autosuggestTextarea.textarea.selectionStart;
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
this.props.onPickEmoji(position, data, needsSpace);
}
focusSpoilerInput = () => {
const spoilerInput = get(this, ['spoilerText', 'input']);
if (spoilerInput) spoilerInput.focus();
}
focusTextarea = () => {
const textarea = get(this, ['autosuggestTextarea', 'textarea']);
if (textarea) textarea.focus();
}
maybeUpdateFocus = prevProps => {
const spoilerUpdated = this.props.spoiler !== prevProps.spoiler;
if (spoilerUpdated) {
switch (this.props.spoiler) {
case true: this.focusSpoilerInput(); break;
case false: this.focusTextarea(); break;
}
}
}
maybeUpdateCursor = prevProps => {
const shouldUpdate = [
// Autosuggest has been updated and
// the cursor position explicitly set
this.props.focusDate !== prevProps.focusDate,
typeof this.props.caretPosition === 'number',
].every(Boolean);
if (shouldUpdate) {
this.setCursor(this.props.caretPosition);
}
}
componentDidUpdate(prevProps) {
this.maybeUpdateFocus(prevProps);
this.maybeUpdateCursor(prevProps);
}
render() {
const { intl, onPaste, showSearch, anyMedia, shouldCondense, autoFocus, isModalOpen, maxTootChars, scheduledStatusCount, features } = this.props;
const condensed = shouldCondense && !this.state.composeFocused && this.isEmpty() && !this.props.isUploading;
const disabled = this.props.isSubmitting;
const text = [this.props.spoilerText, countableText(this.props.text)].join('');
const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || length(text) > maxTootChars || (text.length !== 0 && text.trim().length === 0 && !anyMedia);
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
let publishText = '';
if (this.props.isEditing) {
publishText = intl.formatMessage(messages.saveChanges);
} else if (this.props.privacy === 'direct') {
publishText = (
<>
<Icon src={require('@tabler/icons/mail.svg')} />
{intl.formatMessage(messages.message)}
</>
);
} else if (this.props.privacy === 'private') {
publishText = (
<>
<Icon src={require('@tabler/icons/lock.svg')} />
{intl.formatMessage(messages.publish)}
</>
);
} else {
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
if (this.props.scheduledAt) {
publishText = intl.formatMessage(messages.schedule);
}
return (
<Stack className='w-full' space={1} ref={this.setForm} onClick={this.handleClick}>
{scheduledStatusCount > 0 && (
<Warning
message={(
<FormattedMessage
id='compose_form.scheduled_statuses.message'
defaultMessage='You have scheduled posts. {click_here} to see them.'
values={{ click_here: (
<Link to='/scheduled_statuses'>
<FormattedMessage
id='compose_form.scheduled_statuses.click_here'
defaultMessage='Click here'
/>
</Link>
) }}
/>)
}
/>
)}
<WarningContainer />
{!shouldCondense && <ReplyIndicatorContainer />}
{!shouldCondense && <ReplyMentions />}
<div
className={classNames({
'relative transition-height': true,
'hidden': !this.props.spoiler,
})}
>
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText}
onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown}
disabled={!this.props.spoiler}
ref={this.setSpoilerText}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='border-none shadow-none px-0 py-2 text-base'
autoFocus
/>
</div>
<AutosuggestTextarea
ref={(isModalOpen && shouldCondense) ? null : this.setAutosuggestTextarea}
placeholder={intl.formatMessage(this.props.hasPoll ? messages.pollPlaceholder : messages.placeholder)}
disabled={disabled}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onKeyDown={this.handleKeyDown}
onFocus={this.handleComposeFocus}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={shouldAutoFocus}
condensed={condensed}
id='compose-textarea'
>
{
!condensed &&
<div className='compose-form__modifiers'>
<UploadForm />
<PollForm />
<ScheduleFormContainer />
</div>
}
</AutosuggestTextarea>
<QuotedStatusContainer />
<div
className={classNames('flex flex-wrap items-center justify-between', {
'hidden': condensed,
})}
>
<div className='flex items-center space-x-2'>
{features.media && <UploadButtonContainer />}
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
{features.polls && <PollButtonContainer />}
{features.privacyScopes && <PrivacyDropdownContainer />}
{features.scheduledStatuses && <ScheduleButtonContainer />}
{features.spoilers && <SpoilerButtonContainer />}
{features.richText && <MarkdownButtonContainer />}
</div>
<div className='flex items-center space-x-4 ml-auto'>
{maxTootChars && (
<div className='flex items-center space-x-1'>
<TextCharacterCounter max={maxTootChars} text={text} />
<VisualCharacterCounter max={maxTootChars} text={text} />
</div>
)}
<Button theme='primary' text={publishText} onClick={this.handleSubmit} disabled={disabledButton} />
</div>
</div>
</Stack>
);
}
}

View File

@ -0,0 +1,209 @@
import classNames from 'clsx';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import React, { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
// @ts-ignore
import Overlay from 'react-overlays/lib/Overlay';
import { createSelector } from 'reselect';
import { useEmoji } from 'soapbox/actions/emojis';
import { getSettings, changeSetting } from 'soapbox/actions/settings';
import { IconButton } from 'soapbox/components/ui';
import { EmojiPicker as EmojiPickerAsync } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import EmojiPickerMenu from './emoji-picker-menu';
import type { Emoji as EmojiType } from 'soapbox/components/autosuggest_emoji';
import type { RootState } from 'soapbox/store';
let EmojiPicker: any, Emoji: any; // load asynchronously
const perLine = 8;
const lines = 2;
const DEFAULTS = [
'+1',
'grinning',
'kissing_heart',
'heart_eyes',
'laughing',
'stuck_out_tongue_winking_eye',
'sweat_smile',
'joy',
'yum',
'disappointed',
'thinking_face',
'weary',
'sob',
'sunglasses',
'heart',
'ok_hand',
];
const getFrequentlyUsedEmojis = createSelector([
(state: RootState) => state.settings.get('frequentlyUsedEmojis', ImmutableMap()),
], emojiCounters => {
let emojis = emojiCounters
.keySeq()
.sort((a: number, b: number) => emojiCounters.get(a) - emojiCounters.get(b))
.reverse()
.slice(0, perLine * lines)
.toArray();
if (emojis.length < DEFAULTS.length) {
const uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
}
return emojis;
});
const getCustomEmojis = createSelector([
(state: RootState) => state.custom_emojis as ImmutableList<ImmutableMap<string, string>>,
], emojis => emojis.filter((e) => e.get('visible_in_picker')).sort((a, b) => {
const aShort = a.get('shortcode')!.toLowerCase();
const bShort = b.get('shortcode')!.toLowerCase();
if (aShort < bShort) {
return -1;
} else if (aShort > bShort) {
return 1;
} else {
return 0;
}
}) as ImmutableList<ImmutableMap<string, string>>);
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
people: { id: 'emoji_button.people', defaultMessage: 'People' },
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
});
interface IEmojiPickerDropdown {
onPickEmoji: (data: EmojiType) => void,
button?: JSX.Element,
}
const EmojiPickerDropdown: React.FC<IEmojiPickerDropdown> = ({ onPickEmoji, button }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const customEmojis = useAppSelector((state) => getCustomEmojis(state));
const skinTone = useAppSelector((state) => getSettings(state).get('skinTone') as number);
const frequentlyUsedEmojis = useAppSelector((state) => getFrequentlyUsedEmojis(state));
const [active, setActive] = useState(false);
const [loading, setLoading] = useState(false);
const [placement, setPlacement] = useState<'bottom' | 'top'>();
const target = useRef(null);
const onSkinTone = (skinTone: number) => {
dispatch(changeSetting(['skinTone'], skinTone));
};
const handlePickEmoji = (emoji: EmojiType) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
dispatch(useEmoji(emoji));
if (onPickEmoji) {
onPickEmoji(emoji);
}
};
const onShowDropdown: React.EventHandler<React.KeyboardEvent | React.MouseEvent> = (e) => {
e.stopPropagation();
setActive(true);
if (!EmojiPicker) {
setLoading(true);
EmojiPickerAsync().then(EmojiMart => {
EmojiPicker = EmojiMart.Picker;
Emoji = EmojiMart.Emoji;
setLoading(false);
}).catch(() => {
setLoading(false);
});
}
const { top } = (e.target as any).getBoundingClientRect();
setPlacement(top * 2 < innerHeight ? 'bottom' : 'top');
};
const onHideDropdown = () => {
setActive(false);
};
const onToggle: React.EventHandler<React.KeyboardEvent | React.MouseEvent> = (e) => {
if (!loading && (!(e as React.KeyboardEvent).key || (e as React.KeyboardEvent).key === 'Enter')) {
if (active) {
onHideDropdown();
} else {
onShowDropdown(e);
}
}
};
const handleKeyDown: React.KeyboardEventHandler = e => {
if (e.key === 'Escape') {
onHideDropdown();
}
};
const title = intl.formatMessage(messages.emoji);
return (
<div className='relative' onKeyDown={handleKeyDown}>
<div
ref={target}
title={title}
aria-label={title}
aria-expanded={active}
role='button'
onClick={onToggle}
onKeyDown={onToggle}
tabIndex={0}
>
{button || <IconButton
className={classNames({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'pulse-loading': active && loading,
})}
title='😀'
src={require('@tabler/icons/mood-happy.svg')}
/>}
</div>
<Overlay show={active} placement={placement} target={target.current}>
<EmojiPickerMenu
customEmojis={customEmojis}
loading={loading}
onClose={onHideDropdown}
onPick={handlePickEmoji}
onSkinTone={onSkinTone}
skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}
/>
</Overlay>
</div>
);
};
export { EmojiPicker, Emoji };
export default EmojiPickerDropdown;

View File

@ -0,0 +1,170 @@
import classNames from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { buildCustomEmojis } from '../../../emoji/emoji';
import { EmojiPicker } from './emoji-picker-dropdown';
import ModifierPicker from './modifier-picker';
import type { Emoji } from 'soapbox/components/autosuggest_emoji';
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const categoriesSort = [
'recent',
'custom',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
];
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
people: { id: 'emoji_button.people', defaultMessage: 'People' },
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
});
interface IEmojiPickerMenu {
customEmojis: ImmutableList<ImmutableMap<string, string>>,
loading?: boolean,
onClose: () => void,
onPick: (emoji: Emoji) => void,
onSkinTone: (skinTone: number) => void,
skinTone?: number,
frequentlyUsedEmojis?: Array<string>,
style?: React.CSSProperties,
}
const EmojiPickerMenu: React.FC<IEmojiPickerMenu> = ({
customEmojis,
loading = true,
onClose,
onPick,
onSkinTone,
skinTone,
frequentlyUsedEmojis = [],
style = {},
}) => {
const intl = useIntl();
const node = useRef<HTMLDivElement>(null);
const [modifierOpen, setModifierOpen] = useState(false);
const handleDocumentClick = useCallback(e => {
if (node.current && !node.current.contains(e.target)) {
onClose();
}
}, []);
const getI18n = () => {
return {
search: intl.formatMessage(messages.emoji_search),
notfound: intl.formatMessage(messages.emoji_not_found),
categories: {
search: intl.formatMessage(messages.search_results),
recent: intl.formatMessage(messages.recent),
people: intl.formatMessage(messages.people),
nature: intl.formatMessage(messages.nature),
foods: intl.formatMessage(messages.food),
activity: intl.formatMessage(messages.activity),
places: intl.formatMessage(messages.travel),
objects: intl.formatMessage(messages.objects),
symbols: intl.formatMessage(messages.symbols),
flags: intl.formatMessage(messages.flags),
custom: intl.formatMessage(messages.custom),
},
};
};
const handleClick = (emoji: any) => {
if (!emoji.native) {
emoji.native = emoji.colons;
}
onClose();
onPick(emoji);
};
const handleModifierOpen = () => {
setModifierOpen(true);
};
const handleModifierClose = () => {
setModifierOpen(false);
};
const handleModifierChange = (modifier: number) => {
onSkinTone(modifier);
};
useEffect(() => {
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
return () => {
document.removeEventListener('click', handleDocumentClick, false);
document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any);
};
}, []);
if (loading) {
return <div style={{ width: 299 }} />;
}
const title = intl.formatMessage(messages.emoji);
return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={node}>
<EmojiPicker
perLine={8}
emojiSize={22}
sheetSize={32}
custom={buildCustomEmojis(customEmojis)}
color=''
emoji=''
set='twitter'
title={title}
i18n={getI18n()}
onClick={handleClick}
include={categoriesSort}
recent={frequentlyUsedEmojis}
skin={skinTone}
showPreview={false}
backgroundImageFn={backgroundImageFn}
autoFocus
emojiTooltip
/>
<ModifierPicker
active={modifierOpen}
modifier={skinTone}
onOpen={handleModifierOpen}
onClose={handleModifierClose}
onChange={handleModifierChange}
/>
</div>
);
};
export default EmojiPickerMenu;

View File

@ -0,0 +1,73 @@
import { supportsPassiveEvents } from 'detect-passive-events';
import React, { useCallback, useEffect, useRef } from 'react';
import { Emoji } from './emoji-picker-dropdown';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
interface IModifierPickerMenu {
active: boolean,
onSelect: (modifier: number) => void,
onClose: () => void,
}
const ModifierPickerMenu: React.FC<IModifierPickerMenu> = ({ active, onSelect, onClose }) => {
const node = useRef<HTMLDivElement>(null);
const handleClick: React.MouseEventHandler<HTMLButtonElement> = e => {
onSelect(+e.currentTarget.getAttribute('data-index')! * 1);
};
const handleDocumentClick = useCallback((e => {
if (node.current && !node.current.contains(e.target)) {
onClose();
}
}), []);
const attachListeners = () => {
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
};
const removeListeners = () => {
document.removeEventListener('click', handleDocumentClick, false);
document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any);
};
useEffect(() => {
return () => {
removeListeners();
};
}, []);
useEffect(() => {
if (active) attachListeners();
else removeListeners();
}, [active]);
return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={node}>
<button onClick={handleClick} data-index={1}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={2}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={3}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={4}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={5}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={6}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} />
</button>
</div>
);
};
export default ModifierPickerMenu;

View File

@ -0,0 +1,38 @@
import React from 'react';
import { Emoji } from './emoji-picker-dropdown';
import ModifierPickerMenu from './modifier-picker-menu';
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
interface IModifierPicker {
active: boolean,
modifier?: number,
onOpen: () => void,
onClose: () => void,
onChange: (skinTone: number) => void,
}
const ModifierPicker: React.FC<IModifierPicker> = ({ active, modifier, onOpen, onClose, onChange }) => {
const handleClick = () => {
if (active) {
onClose();
} else {
onOpen();
}
};
const handleSelect = (modifier: number) => {
onChange(modifier);
onClose();
};
return (
<div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={handleClick} backgroundImageFn={backgroundImageFn} />
<ModifierPickerMenu active={active} onSelect={handleSelect} onClose={onClose} />
</div>
);
};
export default ModifierPicker;

View File

@ -1,397 +0,0 @@
import classNames from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl } from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay';
import { IconButton } from 'soapbox/components/ui';
import { buildCustomEmojis } from '../../emoji/emoji';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
people: { id: 'emoji_button.people', defaultMessage: 'People' },
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
});
let EmojiPicker, Emoji; // load asynchronously
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const categoriesSort = [
'recent',
'custom',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
];
class ModifierPickerMenu extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
onSelect: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
handleClick = e => {
this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
}
componentDidUpdate(prevProps) {
if (this.props.active) {
this.attachListeners();
} else {
this.removeListeners();
}
}
componentWillUnmount() {
this.removeListeners();
}
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
attachListeners() {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
removeListeners() {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
render() {
const { active } = this.props;
return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
<button onClick={this.handleClick} data-index={1}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={2}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={3}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={4}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={5}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={6}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
</div>
);
}
}
class ModifierPicker extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
modifier: PropTypes.number,
onChange: PropTypes.func,
onClose: PropTypes.func,
onOpen: PropTypes.func,
};
handleClick = () => {
if (this.props.active) {
this.props.onClose();
} else {
this.props.onOpen();
}
}
handleSelect = modifier => {
this.props.onChange(modifier);
this.props.onClose();
}
render() {
const { active, modifier } = this.props;
return (
<div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
</div>
);
}
}
@injectIntl
class EmojiPickerMenu extends React.PureComponent {
static propTypes = {
custom_emojis: ImmutablePropTypes.list,
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
loading: PropTypes.bool,
onClose: PropTypes.func.isRequired,
onPick: PropTypes.func.isRequired,
style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
intl: PropTypes.object.isRequired,
skinTone: PropTypes.number.isRequired,
onSkinTone: PropTypes.func.isRequired,
};
static defaultProps = {
style: {},
loading: true,
frequentlyUsedEmojis: [],
};
state = {
modifierOpen: false,
placement: null,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
getI18n = () => {
const { intl } = this.props;
return {
search: intl.formatMessage(messages.emoji_search),
notfound: intl.formatMessage(messages.emoji_not_found),
categories: {
search: intl.formatMessage(messages.search_results),
recent: intl.formatMessage(messages.recent),
people: intl.formatMessage(messages.people),
nature: intl.formatMessage(messages.nature),
foods: intl.formatMessage(messages.food),
activity: intl.formatMessage(messages.activity),
places: intl.formatMessage(messages.travel),
objects: intl.formatMessage(messages.objects),
symbols: intl.formatMessage(messages.symbols),
flags: intl.formatMessage(messages.flags),
custom: intl.formatMessage(messages.custom),
},
};
}
handleClick = emoji => {
if (!emoji.native) {
emoji.native = emoji.colons;
}
this.props.onClose();
this.props.onPick(emoji);
}
handleModifierOpen = () => {
this.setState({ modifierOpen: true });
}
handleModifierClose = () => {
this.setState({ modifierOpen: false });
}
handleModifierChange = modifier => {
this.props.onSkinTone(modifier);
}
render() {
const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
if (loading) {
return <div style={{ width: 299 }} />;
}
const title = intl.formatMessage(messages.emoji);
const { modifierOpen } = this.state;
return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<EmojiPicker
perLine={8}
emojiSize={22}
sheetSize={32}
custom={buildCustomEmojis(custom_emojis)}
color=''
emoji=''
set='twitter'
title={title}
i18n={this.getI18n()}
onClick={this.handleClick}
include={categoriesSort}
recent={frequentlyUsedEmojis}
skin={skinTone}
showPreview={false}
backgroundImageFn={backgroundImageFn}
autoFocus
emojiTooltip
/>
<ModifierPicker
active={modifierOpen}
modifier={skinTone}
onOpen={this.handleModifierOpen}
onClose={this.handleModifierClose}
onChange={this.handleModifierChange}
/>
</div>
);
}
}
export default @injectIntl
class EmojiPickerDropdown extends React.PureComponent {
static propTypes = {
custom_emojis: ImmutablePropTypes.list,
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
intl: PropTypes.object.isRequired,
onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
};
state = {
active: false,
loading: false,
};
setRef = (c) => {
this.dropdown = c;
}
onShowDropdown = (e) => {
e.stopPropagation();
this.setState({ active: true });
if (!EmojiPicker) {
this.setState({ loading: true });
EmojiPickerAsync().then(EmojiMart => {
EmojiPicker = EmojiMart.Picker;
Emoji = EmojiMart.Emoji;
this.setState({ loading: false });
}).catch(() => {
this.setState({ loading: false });
});
}
const { top } = e.target.getBoundingClientRect();
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
}
onHideDropdown = () => {
this.setState({ active: false });
}
onToggle = (e) => {
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
if (this.state.active) {
this.onHideDropdown();
} else {
this.onShowDropdown(e);
}
}
}
handleKeyDown = e => {
if (e.key === 'Escape') {
this.onHideDropdown();
}
}
setTargetRef = c => {
this.target = c;
}
findTarget = () => {
return this.target;
}
render() {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state;
return (
<div className='relative' onKeyDown={this.handleKeyDown}>
<div
ref={this.setTargetRef}
title={title}
aria-label={title}
aria-expanded={active}
role='button'
onClick={this.onToggle}
onKeyDown={this.onToggle}
tabIndex={0}
>
{button || <IconButton
className={classNames({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'pulse-loading': active && loading,
})}
alt='😀'
src={require('@tabler/icons/mood-happy.svg')}
/>}
</div>
<Overlay show={active} placement={placement} target={this.findTarget}>
<EmojiPickerMenu
custom_emojis={this.props.custom_emojis}
loading={loading}
onClose={this.onHideDropdown}
onPick={onPickEmoji}
onSkinTone={onSkinTone}
skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}
/>
</Overlay>
</div>
);
}
}

View File

@ -1,6 +1,9 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeComposeContentType } from 'soapbox/actions/compose';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import ComposeFormButton from './compose_form_button';
const messages = defineMessages({
@ -9,12 +12,16 @@ const messages = defineMessages({
});
interface IMarkdownButton {
active?: boolean,
onClick: () => void,
composeId: string,
}
const MarkdownButton: React.FC<IMarkdownButton> = ({ active, onClick }) => {
const MarkdownButton: React.FC<IMarkdownButton> = ({ composeId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const active = useCompose(composeId).content_type === 'text/markdown';
const onClick = () => dispatch(changeComposeContentType(composeId, active ? 'text/plain' : 'text/markdown'));
return (
<ComposeFormButton

View File

@ -1,6 +1,9 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { addPoll, removePoll } from 'soapbox/actions/compose';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import ComposeFormButton from './compose_form_button';
const messages = defineMessages({
@ -9,14 +12,26 @@ const messages = defineMessages({
});
interface IPollButton {
composeId: string
disabled?: boolean,
unavailable?: boolean,
active?: boolean,
onClick: () => void,
}
const PollButton: React.FC<IPollButton> = ({ active, unavailable, disabled, onClick }) => {
const PollButton: React.FC<IPollButton> = ({ composeId, disabled }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const compose = useCompose(composeId);
const unavailable = compose.is_uploading;
const active = compose.poll !== null;
const onClick = () => {
if (active) {
dispatch(removePoll(composeId));
} else {
dispatch(addPoll(composeId));
}
};
if (unavailable) {
return null;

View File

@ -4,7 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { addPollOption, changePollOption, changePollSettings, clearComposeSuggestions, fetchComposeSuggestions, removePoll, removePollOption, selectComposeSuggestion } from 'soapbox/actions/compose';
import AutosuggestInput from 'soapbox/components/autosuggest_input';
import { Button, Divider, HStack, Stack, Text, Toggle } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useCompose } from 'soapbox/hooks';
import DurationSelector from './duration-selector';
@ -26,6 +26,7 @@ const messages = defineMessages({
});
interface IOption {
composeId: string
index: number
maxChars: number
numOptions: number
@ -35,21 +36,20 @@ interface IOption {
title: string
}
const Option = (props: IOption) => {
const {
index,
maxChars,
numOptions,
onChange,
onRemove,
onRemovePoll,
title,
} = props;
const Option: React.FC<IOption> = ({
composeId,
index,
maxChars,
numOptions,
onChange,
onRemove,
onRemovePoll,
title,
}) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const suggestions = useAppSelector((state) => state.compose.suggestions);
const suggestions = useCompose(composeId).suggestions;
const handleOptionTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => onChange(index, event.target.value);
@ -61,13 +61,13 @@ const Option = (props: IOption) => {
}
};
const onSuggestionsClearRequested = () => dispatch(clearComposeSuggestions());
const onSuggestionsClearRequested = () => dispatch(clearComposeSuggestions(composeId));
const onSuggestionsFetchRequested = (token: string) => dispatch(fetchComposeSuggestions(token));
const onSuggestionsFetchRequested = (token: string) => dispatch(fetchComposeSuggestions(composeId, token));
const onSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => {
if (token && typeof token === 'string') {
dispatch(selectComposeSuggestion(tokenStart, token, value, ['poll', 'options', index]));
dispatch(selectComposeSuggestion(composeId, tokenStart, token, value, ['poll', 'options', index]));
}
};
@ -102,26 +102,32 @@ const Option = (props: IOption) => {
);
};
const PollForm = () => {
interface IPollForm {
composeId: string,
}
const PollForm: React.FC<IPollForm> = ({ composeId }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const compose = useCompose(composeId);
const pollLimits = useAppSelector((state) => state.instance.getIn(['configuration', 'polls']) as any);
const options = useAppSelector((state) => state.compose.poll?.options);
const expiresIn = useAppSelector((state) => state.compose.poll?.expires_in);
const isMultiple = useAppSelector((state) => state.compose.poll?.multiple);
const options = compose.poll?.options;
const expiresIn = compose.poll?.expires_in;
const isMultiple = compose.poll?.multiple;
const maxOptions = pollLimits.get('max_options');
const maxOptionChars = pollLimits.get('max_characters_per_option');
const onRemoveOption = (index: number) => dispatch(removePollOption(index));
const onChangeOption = (index: number, title: string) => dispatch(changePollOption(index, title));
const handleAddOption = () => dispatch(addPollOption(''));
const onRemoveOption = (index: number) => dispatch(removePollOption(composeId, index));
const onChangeOption = (index: number, title: string) => dispatch(changePollOption(composeId, index, title));
const handleAddOption = () => dispatch(addPollOption(composeId, ''));
const onChangeSettings = (expiresIn: string | number | undefined, isMultiple?: boolean) =>
dispatch(changePollSettings(expiresIn, isMultiple));
dispatch(changePollSettings(composeId, expiresIn, isMultiple));
const handleSelectDuration = (value: number) => onChangeSettings(value, isMultiple);
const handleToggleMultiple = () => onChangeSettings(expiresIn, !isMultiple);
const onRemovePoll = () => dispatch(removePoll());
const onRemovePoll = () => dispatch(removePoll(composeId));
if (!options) {
return null;
@ -132,6 +138,7 @@ const PollForm = () => {
<Stack space={2}>
{options.map((title: string, i: number) => (
<Option
composeId={composeId}
title={title}
key={i}
index={i}

View File

@ -6,8 +6,12 @@ import { spring } from 'react-motion';
// @ts-ignore
import Overlay from 'react-overlays/lib/Overlay';
import { changeComposeVisibility } from 'soapbox/actions/compose';
import { closeModal, openModal } from 'soapbox/actions/modals';
import Icon from 'soapbox/components/icon';
import { IconButton } from 'soapbox/components/ui';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is_mobile';
import Motion from '../../ui/util/optional_motion';
@ -50,7 +54,7 @@ const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, pla
const handleKeyDown: React.KeyboardEventHandler = e => {
const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => item.value === value);
let element = null;
let element: ChildNode | null | undefined = null;
switch (e.key) {
case 'Escape':
@ -136,27 +140,22 @@ const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, pla
};
interface IPrivacyDropdown {
isUserTouching: () => boolean,
isModalOpen: boolean,
onModalOpen: (opts: any) => void,
onModalClose: () => void,
value: string,
onChange: (value: string | null) => void,
unavailable: boolean,
composeId: string,
}
const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
isUserTouching,
onChange,
onModalClose,
onModalOpen,
value,
unavailable,
composeId,
}) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const node = useRef<HTMLDivElement>(null);
const activeElement = useRef<HTMLElement | null>(null);
const compose = useCompose(composeId);
const value = compose.privacy;
const unavailable = compose.id;
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState('bottom');
@ -167,6 +166,12 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
{ icon: require('@tabler/icons/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) },
];
const onChange = (value: string | null) => value && dispatch(changeComposeVisibility(composeId, value));
const onModalOpen = (props: Record<string, any>) => dispatch(openModal('ACTIONS', props));
const onModalClose = () => dispatch(closeModal('ACTIONS'));
const handleToggle: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (isUserTouching()) {
if (open) {

View File

@ -1,21 +1,28 @@
import React from 'react';
import React, { useCallback } from 'react';
import { FormattedList, FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { openModal } from 'soapbox/actions/modals';
import { useAppSelector } from 'soapbox/hooks';
import { useAppSelector, useCompose } from 'soapbox/hooks';
import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose';
import { makeGetStatus } from 'soapbox/selectors';
import { getFeatures } from 'soapbox/utils/features';
import type { Status as StatusEntity } from 'soapbox/types/entities';
const ReplyMentions: React.FC = () => {
const dispatch = useDispatch();
const instance = useAppSelector((state) => state.instance);
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: state.compose.in_reply_to! }));
interface IReplyMentions {
composeId: string,
}
const to = useAppSelector((state) => state.compose.to);
const ReplyMentions: React.FC<IReplyMentions> = ({ composeId }) => {
const dispatch = useDispatch();
const getStatus = useCallback(makeGetStatus(), []);
const compose = useCompose(composeId);
const instance = useAppSelector((state) => state.instance);
const status = useAppSelector<StatusEntity | null>(state => getStatus(state, { id: compose.in_reply_to! }));
const to = compose.to;
const account = useAppSelector((state) => state.accounts.get(state.me));
const { explicitAddressing } = getFeatures(instance);

View File

@ -1,6 +1,9 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { addSchedule, removeSchedule } from 'soapbox/actions/compose';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import ComposeFormButton from './compose_form_button';
const messages = defineMessages({
@ -9,17 +12,25 @@ const messages = defineMessages({
});
interface IScheduleButton {
disabled: boolean,
active: boolean,
unavailable: boolean,
onClick: () => void,
composeId: string,
disabled?: boolean,
}
const ScheduleButton: React.FC<IScheduleButton> = ({ active, unavailable, disabled, onClick }) => {
const ScheduleButton: React.FC<IScheduleButton> = ({ composeId, disabled }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const compose = useCompose(composeId);
const active = !!compose.schedule;
const unavailable = !!compose.id;
const handleClick = () => {
onClick();
if (active) {
dispatch(removeSchedule(composeId));
} else {
dispatch(addSchedule(composeId));
}
};
if (unavailable) {

View File

@ -9,7 +9,7 @@ import IconButton from 'soapbox/components/icon_button';
import { HStack, Stack, Text } from 'soapbox/components/ui';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { DatePicker } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
const isCurrentOrFutureDate = (date: Date) => {
return date && new Date().setHours(0, 0, 0, 0) <= new Date(date).setHours(0, 0, 0, 0);
@ -27,19 +27,23 @@ const messages = defineMessages({
remove: { id: 'schedule.remove', defaultMessage: 'Remove schedule' },
});
const ScheduleForm: React.FC = () => {
export interface IScheduleForm {
composeId: string,
}
const ScheduleForm: React.FC<IScheduleForm> = ({ composeId }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const scheduledAt = useAppSelector((state) => state.compose.schedule);
const scheduledAt = useCompose(composeId).schedule;
const active = !!scheduledAt;
const onSchedule = (date: Date) => {
dispatch(setSchedule(date));
dispatch(setSchedule(composeId, date));
};
const handleRemove = (e: React.MouseEvent<HTMLButtonElement>) => {
dispatch(removeSchedule());
dispatch(removeSchedule(composeId));
e.preventDefault();
};

View File

@ -9,6 +9,7 @@ import { useHistory } from 'react-router-dom';
import {
changeSearch,
clearSearch,
clearSearchResults,
setSearchAccount,
showSearch,
submitSearch,
@ -72,7 +73,7 @@ const Search = (props: ISearch) => {
event.preventDefault();
if (value.length > 0 || submitted) {
dispatch(clearSearch());
dispatch(clearSearchResults());
}
};

View File

@ -2,7 +2,7 @@ import classNames from 'clsx';
import React, { useEffect, useRef } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { clearSearch, expandSearch, setFilter } from 'soapbox/actions/search';
import { expandSearch, setFilter, setSearchAccount } from 'soapbox/actions/search';
import { fetchTrendingStatuses } from 'soapbox/actions/trending_statuses';
import Hashtag from 'soapbox/components/hashtag';
import IconButton from 'soapbox/components/icon_button';
@ -43,7 +43,7 @@ const SearchResults = () => {
const handleLoadMore = () => dispatch(expandSearch(selectedFilter));
const handleClearSearch = () => dispatch(clearSearch());
const handleUnsetAccount = () => dispatch(setSearchAccount(null));
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter));
@ -196,7 +196,7 @@ const SearchResults = () => {
<>
{filterByAccount ? (
<HStack className='mb-4 pb-4 px-2 border-solid border-b border-gray-200 dark:border-gray-800' space={2}>
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/x.svg')} onClick={handleClearSearch} />
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/x.svg')} onClick={handleUnsetAccount} />
<Text>
<FormattedMessage
id='search_results.filter_message'

View File

@ -3,23 +3,29 @@ import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { changeComposeSensitivity } from 'soapbox/actions/compose';
import { FormGroup, Checkbox } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
const messages = defineMessages({
marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' },
});
interface ISensitiveButton {
composeId: string,
}
/** Button to mark own media as sensitive. */
const SensitiveButton: React.FC = () => {
const SensitiveButton: React.FC<ISensitiveButton> = ({ composeId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const active = useAppSelector(state => state.compose.sensitive === true);
const disabled = useAppSelector(state => state.compose.spoiler === true);
const compose = useCompose(composeId);
const active = compose.sensitive === true;
const disabled = compose.spoiler === true;
const onClick = () => {
dispatch(changeComposeSensitivity());
dispatch(changeComposeSensitivity(composeId));
};
return (

View File

@ -1,6 +1,9 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeComposeSpoilerness } from 'soapbox/actions/compose';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import ComposeFormButton from './compose_form_button';
const messages = defineMessages({
@ -9,12 +12,17 @@ const messages = defineMessages({
});
interface ISpoilerButton {
active?: boolean,
onClick: () => void,
composeId: string,
}
const SpoilerButton: React.FC<ISpoilerButton> = ({ active, onClick }) => {
const SpoilerButton: React.FC<ISpoilerButton> = ({ composeId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const active = useCompose(composeId).spoiler;
const onClick = () =>
dispatch(changeComposeSpoilerness(composeId));
return (
<ComposeFormButton

View File

@ -1,12 +1,18 @@
import React from 'react';
import UploadProgress from 'soapbox/components/upload-progress';
import { useAppSelector } from 'soapbox/hooks';
import { useCompose } from 'soapbox/hooks';
interface IComposeUploadProgress {
composeId: string,
}
/** File upload progress bar for post composer. */
const ComposeUploadProgress = () => {
const active = useAppSelector((state) => state.compose.is_uploading);
const progress = useAppSelector((state) => state.compose.progress);
const ComposeUploadProgress: React.FC<IComposeUploadProgress> = ({ composeId }) => {
const compose = useCompose(composeId);
const active = compose.is_uploading;
const progress = compose.progress;
if (!active) {
return null;

View File

@ -1,17 +1,19 @@
import classNames from 'clsx';
import { List as ImmutableList } from 'immutable';
import React, { useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { spring } from 'react-motion';
import { useHistory } from 'react-router-dom';
import { undoUploadCompose, changeUploadCompose, submitCompose } from 'soapbox/actions/compose';
import { openModal } from 'soapbox/actions/modals';
import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon';
import IconButton from 'soapbox/components/icon_button';
import { useAppDispatch, useAppSelector, useCompose } from 'soapbox/hooks';
import Motion from '../../ui/util/optional_motion';
import type { Map as ImmutableMap } from 'immutable';
const bookIcon = require('@tabler/icons/book.svg');
const fileCodeIcon = require('@tabler/icons/file-code.svg');
const fileSpreadsheetIcon = require('@tabler/icons/file-spreadsheet.svg');
@ -60,18 +62,17 @@ const messages = defineMessages({
});
interface IUpload {
media: ImmutableMap<string, any>,
descriptionLimit: number,
onUndo: (attachmentId: string) => void,
onDescriptionChange: (attachmentId: string, description: string) => void,
onOpenFocalPoint: (attachmentId: string) => void,
onOpenModal: (attachments: ImmutableMap<string, any>) => void,
onSubmit: (history: ReturnType<typeof useHistory>) => void,
id: string,
composeId: string,
}
const Upload: React.FC<IUpload> = (props) => {
const Upload: React.FC<IUpload> = ({ composeId, id }) => {
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
const media = useCompose(composeId).media_attachments.find(item => item.get('id') === id)!;
const descriptionLimit = useAppSelector((state) => state.instance.get('description_limit'));
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
@ -85,12 +86,12 @@ const Upload: React.FC<IUpload> = (props) => {
const handleSubmit = () => {
handleInputBlur();
props.onSubmit(history);
dispatch(submitCompose(composeId, history));
};
const handleUndoClick: React.MouseEventHandler = e => {
e.stopPropagation();
props.onUndo(props.media.get('id'));
dispatch(undoUploadCompose(composeId, media.id));
};
const handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
@ -118,22 +119,22 @@ const Upload: React.FC<IUpload> = (props) => {
setDirtyDescription(null);
if (dirtyDescription !== null) {
props.onDescriptionChange(props.media.get('id'), dirtyDescription);
dispatch(changeUploadCompose(composeId, media.id, { dirtyDescription }));
}
};
const handleOpenModal = () => {
props.onOpenModal(props.media);
dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 }));
};
const active = hovered || focused;
const description = dirtyDescription || (dirtyDescription !== '' && props.media.get('description')) || '';
const focusX = props.media.getIn(['meta', 'focus', 'x']) as number | undefined;
const focusY = props.media.getIn(['meta', 'focus', 'y']) as number | undefined;
const description = dirtyDescription || (dirtyDescription !== '' && media.description) || '';
const focusX = media.meta.getIn(['focus', 'x']) as number | undefined;
const focusY = media.meta.getIn(['focus', 'y']) as number | undefined;
const x = focusX ? ((focusX / 2) + .5) * 100 : undefined;
const y = focusY ? ((focusY / -2) + .5) * 100 : undefined;
const mediaType = props.media.get('type');
const mimeType = props.media.getIn(['pleroma', 'mime_type']) as string | undefined;
const mediaType = media.type;
const mimeType = media.pleroma.get('mime_type') as string | undefined;
const uploadIcon = mediaType === 'unknown' && (
<Icon
@ -144,14 +145,14 @@ const Upload: React.FC<IUpload> = (props) => {
return (
<div className='compose-form__upload' tabIndex={0} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={handleClick} role='button'>
<Blurhash hash={props.media.get('blurhash')} className='media-gallery__preview' />
<Blurhash hash={media.blurhash} className='media-gallery__preview' />
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => (
<div
className={classNames('compose-form__upload-thumbnail', `${mediaType}`)}
style={{
transform: `scale(${scale})`,
backgroundImage: mediaType === 'image' ? `url(${props.media.get('preview_url')})` : undefined,
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined }}
>
<div className={classNames('compose-form__upload__actions', { active })}>
@ -162,7 +163,7 @@ const Upload: React.FC<IUpload> = (props) => {
/>
{/* Only display the "Preview" button for a valid attachment with a URL */}
{(mediaType !== 'unknown' && Boolean(props.media.get('url'))) && (
{(mediaType !== 'unknown' && Boolean(media.url)) && (
<IconButton
onClick={handleOpenModal}
src={require('@tabler/icons/zoom-in.svg')}
@ -178,7 +179,7 @@ const Upload: React.FC<IUpload> = (props) => {
<textarea
placeholder={intl.formatMessage(messages.description)}
value={description}
maxLength={props.descriptionLimit}
maxLength={descriptionLimit}
onFocus={handleInputFocus}
onChange={handleInputChange}
onBlur={handleInputBlur}
@ -190,7 +191,7 @@ const Upload: React.FC<IUpload> = (props) => {
<div className='compose-form__upload-preview'>
{mediaType === 'video' && (
<video autoPlay playsInline muted loop>
<source src={props.media.get('preview_url')} />
<source src={media.preview_url} />
</video>
)}
{uploadIcon}

View File

@ -1,5 +1,5 @@
import React, { useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { defineMessages, IntlShape, useIntl } from 'react-intl';
import { IconButton } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
@ -14,12 +14,12 @@ const onlyImages = (types: ImmutableList<string>) => {
return Boolean(types && types.every(type => type.startsWith('image/')));
};
interface IUploadButton {
export interface IUploadButton {
disabled?: boolean,
unavailable?: boolean,
onSelectFile: (files: FileList) => void,
onSelectFile: (files: FileList, intl: IntlShape) => void,
style?: React.CSSProperties,
resetFileKey: number,
resetFileKey: number | null,
}
const UploadButton: React.FC<IUploadButton> = ({
@ -35,7 +35,7 @@ const UploadButton: React.FC<IUploadButton> = ({
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
if (e.target.files?.length) {
onSelectFile(e.target.files);
onSelectFile(e.target.files, intl);
}
};

View File

@ -1,31 +1,35 @@
import classNames from 'clsx';
import React from 'react';
import { useAppSelector } from 'soapbox/hooks';
import { useCompose } from 'soapbox/hooks';
import SensitiveButton from '../components/sensitive-button';
import UploadProgress from '../components/upload-progress';
import UploadContainer from '../containers/upload_container';
import SensitiveButton from './sensitive-button';
import Upload from './upload';
import UploadProgress from './upload-progress';
import type { Attachment as AttachmentEntity } from 'soapbox/types/entities';
const UploadForm = () => {
const mediaIds = useAppSelector((state) => state.compose.media_attachments.map((item: AttachmentEntity) => item.id));
interface IUploadForm {
composeId: string,
}
const UploadForm: React.FC<IUploadForm> = ({ composeId }) => {
const mediaIds = useCompose(composeId).media_attachments.map((item: AttachmentEntity) => item.id);
const classes = classNames('compose-form__uploads-wrapper', {
'contains-media': mediaIds.size !== 0,
});
return (
<div className='compose-form__upload-wrapper'>
<UploadProgress />
<UploadProgress composeId={composeId} />
<div className={classes}>
{mediaIds.map((id: string) => (
<UploadContainer id={id} key={id} />
<Upload id={id} key={id} composeId={composeId} />
))}
</div>
{!mediaIds.isEmpty() && <SensitiveButton />}
{!mediaIds.isEmpty() && <SensitiveButton composeId={composeId} />}
</div>
);
};

View File

@ -1,87 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import {
changeCompose,
submitCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
changeComposeSpoilerText,
insertEmojiCompose,
uploadCompose,
} from 'soapbox/actions/compose';
import { getFeatures } from 'soapbox/utils/features';
import ComposeForm from '../components/compose_form';
const mapStateToProps = state => {
const instance = state.get('instance');
return {
text: state.getIn(['compose', 'text']),
suggestions: state.getIn(['compose', 'suggestions']),
spoiler: state.getIn(['compose', 'spoiler']),
spoilerText: state.getIn(['compose', 'spoiler_text']),
privacy: state.getIn(['compose', 'privacy']),
focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']),
hasPoll: !!state.getIn(['compose', 'poll']),
isSubmitting: state.getIn(['compose', 'is_submitting']),
isEditing: state.getIn(['compose', 'id']) !== null,
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
isUploading: state.getIn(['compose', 'is_uploading']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
isModalOpen: Boolean(state.get('modals').size && state.get('modals').last().modalType === 'COMPOSE'),
maxTootChars: state.getIn(['instance', 'configuration', 'statuses', 'max_characters']),
scheduledAt: state.getIn(['compose', 'schedule']),
scheduledStatusCount: state.get('scheduled_statuses').size,
features: getFeatures(instance),
};
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onChange(text) {
dispatch(changeCompose(text));
},
onSubmit(router, group) {
dispatch(submitCompose(router, group));
},
onClearSuggestions() {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions(token) {
dispatch(fetchComposeSuggestions(token));
},
onSuggestionSelected(position, token, suggestion, path) {
dispatch(selectComposeSuggestion(position, token, suggestion, path));
},
onChangeSpoilerText(checked) {
dispatch(changeComposeSpoilerText(checked));
},
onPaste(files) {
dispatch(uploadCompose(files, intl));
},
onPickEmoji(position, data, needsSpace) {
dispatch(insertEmojiCompose(position, data, needsSpace));
},
});
function mergeProps(stateProps, dispatchProps, ownProps) {
return Object.assign({}, ownProps, {
...stateProps,
...dispatchProps,
});
}
export default injectIntl(connect(mapStateToProps, mapDispatchToProps, mergeProps)(ComposeForm));

View File

@ -1,84 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { useEmoji } from '../../../actions/emojis';
import { getSettings, changeSetting } from '../../../actions/settings';
import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
const perLine = 8;
const lines = 2;
const DEFAULTS = [
'+1',
'grinning',
'kissing_heart',
'heart_eyes',
'laughing',
'stuck_out_tongue_winking_eye',
'sweat_smile',
'joy',
'yum',
'disappointed',
'thinking_face',
'weary',
'sob',
'sunglasses',
'heart',
'ok_hand',
];
const getFrequentlyUsedEmojis = createSelector([
state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
], emojiCounters => {
let emojis = emojiCounters
.keySeq()
.sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
.reverse()
.slice(0, perLine * lines)
.toArray();
if (emojis.length < DEFAULTS.length) {
const uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
}
return emojis;
});
const getCustomEmojis = createSelector([
state => state.get('custom_emojis'),
], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
const aShort = a.get('shortcode').toLowerCase();
const bShort = b.get('shortcode').toLowerCase();
if (aShort < bShort) {
return -1;
} else if (aShort > bShort) {
return 1;
} else {
return 0;
}
}));
const mapStateToProps = state => ({
custom_emojis: getCustomEmojis(state),
skinTone: getSettings(state).get('skinTone'),
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
});
const mapDispatchToProps = (dispatch, props) => ({
onSkinTone: skinTone => {
dispatch(changeSetting(['skinTone'], skinTone));
},
onPickEmoji: emoji => {
dispatch(useEmoji(emoji)); // eslint-disable-line react-hooks/rules-of-hooks
if (props.onPickEmoji) {
props.onPickEmoji(emoji);
}
},
});
export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);

View File

@ -1,23 +0,0 @@
import { connect } from 'react-redux';
import { changeComposeContentType } from '../../../actions/compose';
import MarkdownButton from '../components/markdown_button';
const mapStateToProps = (state, { intl }) => {
return {
active: state.getIn(['compose', 'content_type']) === 'text/markdown',
};
};
const mapDispatchToProps = dispatch => ({
onClick() {
dispatch((_, getState) => {
const active = getState().getIn(['compose', 'content_type']) === 'text/markdown';
dispatch(changeComposeContentType(active ? 'text/plain' : 'text/markdown'));
});
},
});
export default connect(mapStateToProps, mapDispatchToProps)(MarkdownButton);

View File

@ -1,25 +0,0 @@
import { connect } from 'react-redux';
import { addPoll, removePoll } from '../../../actions/compose';
import PollButton from '../components/poll_button';
const mapStateToProps = state => ({
unavailable: state.getIn(['compose', 'is_uploading']),
active: state.getIn(['compose', 'poll']) !== null,
});
const mapDispatchToProps = dispatch => ({
onClick() {
dispatch((_, getState) => {
if (getState().getIn(['compose', 'poll'])) {
dispatch(removePoll());
} else {
dispatch(addPoll());
}
});
},
});
export default connect(mapStateToProps, mapDispatchToProps)(PollButton);

View File

@ -1,28 +0,0 @@
import { connect } from 'react-redux';
import { changeComposeVisibility } from '../../../actions/compose';
import { openModal, closeModal } from '../../../actions/modals';
import { isUserTouching } from '../../../is_mobile';
import PrivacyDropdown from '../components/privacy_dropdown';
const mapStateToProps = state => ({
isModalOpen: Boolean(state.get('modals').size && state.get('modals').last().modalType === 'ACTIONS'),
value: state.getIn(['compose', 'privacy']),
unavailable: !!state.getIn(['compose', 'id']),
});
const mapDispatchToProps = dispatch => ({
onChange(value) {
dispatch(changeComposeVisibility(value));
},
isUserTouching,
onModalOpen: props => dispatch(openModal('ACTIONS', props)),
onModalClose: () => {
dispatch(closeModal('ACTIONS'));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);

View File

@ -1,16 +1,20 @@
import React from 'react';
import React, { useCallback } from 'react';
import { cancelQuoteCompose } from 'soapbox/actions/compose';
import QuotedStatus from 'soapbox/components/quoted-status';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetStatus } from 'soapbox/selectors';
const getStatus = makeGetStatus();
interface IQuotedStatusContainer {
composeId: string,
}
/** QuotedStatus shown in post composer. */
const QuotedStatusContainer: React.FC = () => {
const QuotedStatusContainer: React.FC<IQuotedStatusContainer> = ({ composeId }) => {
const dispatch = useAppDispatch();
const status = useAppSelector(state => getStatus(state, { id: state.compose.quote! }));
const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector(state => getStatus(state, { id: state.compose.get(composeId)?.quote! }));
const onCancel = () => {
dispatch(cancelQuoteCompose());

View File

@ -1,31 +0,0 @@
import { connect } from 'react-redux';
import { cancelReplyCompose } from '../../../actions/compose';
import { makeGetStatus } from '../../../selectors';
import ReplyIndicator from '../components/reply_indicator';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = state => {
const statusId = state.getIn(['compose', 'in_reply_to']);
const editing = !!state.getIn(['compose', 'id']);
return {
status: getStatus(state, { id: statusId }),
hideActions: editing,
};
};
return mapStateToProps;
};
const mapDispatchToProps = dispatch => ({
onCancel() {
dispatch(cancelReplyCompose());
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);

View File

@ -0,0 +1,35 @@
import { connect } from 'react-redux';
import { cancelReplyCompose } from 'soapbox/actions/compose';
import { makeGetStatus } from 'soapbox/selectors';
import ReplyIndicator from '../components/reply_indicator';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Status } from 'soapbox/types/entities';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state: RootState, { composeId }: { composeId: string }) => {
const statusId = state.compose.get(composeId)?.in_reply_to!;
const editing = !!state.compose.get(composeId)?.id;
return {
status: getStatus(state, { id: statusId }) as Status,
hideActions: editing,
};
};
return mapStateToProps;
};
const mapDispatchToProps = (dispatch: AppDispatch) => ({
onCancel() {
dispatch(cancelReplyCompose());
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);

View File

@ -1,25 +0,0 @@
import { connect } from 'react-redux';
import { addSchedule, removeSchedule } from '../../../actions/compose';
import ScheduleButton from '../components/schedule_button';
const mapStateToProps = state => ({
active: state.getIn(['compose', 'schedule']) ? true : false,
unavailable: !!state.getIn(['compose', 'id']),
});
const mapDispatchToProps = dispatch => ({
onClick() {
dispatch((dispatch, getState) => {
if (getState().getIn(['compose', 'schedule'])) {
dispatch(removeSchedule());
} else {
dispatch(addSchedule());
}
});
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleButton);

View File

@ -1,16 +0,0 @@
import React from 'react';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { ScheduleForm } from 'soapbox/features/ui/util/async-components';
export default class ScheduleFormContainer extends React.PureComponent {
render() {
return (
<BundleContainer fetchComponent={ScheduleForm}>
{Component => <Component {...this.props} />}
</BundleContainer>
);
}
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { ScheduleForm } from 'soapbox/features/ui/util/async-components';
import type { IScheduleForm } from '../components/schedule_form';
const ScheduleFormContainer: React.FC<IScheduleForm> = (props) => (
<BundleContainer fetchComponent={ScheduleForm}>
{Component => <Component {...props} />}
</BundleContainer>
);
export default ScheduleFormContainer;

View File

@ -1,18 +0,0 @@
import { connect } from 'react-redux';
import { changeComposeSpoilerness } from '../../../actions/compose';
import SpoilerButton from '../components/spoiler_button';
const mapStateToProps = (state, { intl }) => ({
active: state.getIn(['compose', 'spoiler']),
});
const mapDispatchToProps = dispatch => ({
onClick() {
dispatch(changeComposeSpoilerness());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(SpoilerButton);

View File

@ -1,20 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { uploadCompose } from '../../../actions/compose';
import UploadButton from '../components/upload_button';
const mapStateToProps = state => ({
disabled: state.getIn(['compose', 'is_uploading']),
resetFileKey: state.getIn(['compose', 'resetFileKey']),
});
const mapDispatchToProps = (dispatch, { intl }) => ({
onSelectFile(files) {
dispatch(uploadCompose(files, intl));
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(UploadButton));

View File

@ -0,0 +1,23 @@
import { connect } from 'react-redux';
import { uploadCompose } from 'soapbox/actions/compose';
import UploadButton from '../components/upload_button';
import type { IntlShape } from 'react-intl';
import type { AppDispatch, RootState } from 'soapbox/store';
const mapStateToProps = (state: RootState, { composeId }: { composeId: string }) => ({
disabled: state.compose.get(composeId)?.is_uploading,
resetFileKey: state.compose.get(composeId)?.resetFileKey!,
});
const mapDispatchToProps = (dispatch: AppDispatch, { composeId }: { composeId: string }) => ({
onSelectFile(files: FileList, intl: IntlShape) {
dispatch(uploadCompose(composeId, files, intl));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);

View File

@ -1,37 +0,0 @@
import { List as ImmutableList } from 'immutable';
import { connect } from 'react-redux';
import { undoUploadCompose, changeUploadCompose, submitCompose } from '../../../actions/compose';
import { openModal } from '../../../actions/modals';
import Upload from '../components/upload';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
descriptionLimit: state.getIn(['instance', 'description_limit']),
});
const mapDispatchToProps = dispatch => ({
onUndo: id => {
dispatch(undoUploadCompose(id));
},
onDescriptionChange: (id, description) => {
dispatch(changeUploadCompose(id, { description }));
},
onOpenFocalPoint: id => {
dispatch(openModal('FOCAL_POINT', { id }));
},
onOpenModal: media => {
dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 }));
},
onSubmit(router) {
dispatch(submitCompose(router));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Upload);

View File

@ -1,10 +0,0 @@
import { connect } from 'react-redux';
import UploadProgress from '../components/upload-progress';
const mapStateToProps = state => ({
active: state.getIn(['compose', 'is_uploading']),
progress: state.getIn(['compose', 'progress']),
});
export default connect(mapStateToProps)(UploadProgress);

View File

@ -1,23 +1,26 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { useAppSelector, useCompose } from 'soapbox/hooks';
import Warning from '../components/warning';
const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i;
const mapStateToProps = state => {
const me = state.get('me');
return {
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
};
};
interface IWarningWrapper {
composeId: string,
}
const WarningWrapper: React.FC<IWarningWrapper> = ({ composeId }) => {
const compose = useCompose(composeId);
const me = useAppSelector((state) => state.me);
const needsLockWarning = useAppSelector((state) => compose.privacy === 'private' && !state.accounts.get(me)!.locked);
const hashtagWarning = compose.privacy !== 'public' && APPROX_HASHTAG_RE.test(compose.text);
const directMessageWarning = compose.privacy === 'direct';
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
if (needsLockWarning) {
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <Link to='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></Link> }} />} />;
}
@ -40,10 +43,4 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
return null;
};
WarningWrapper.propTypes = {
needsLockWarning: PropTypes.bool,
hashtagWarning: PropTypes.bool,
directMessageWarning: PropTypes.bool,
};
export default connect(mapStateToProps)(WarningWrapper);
export default WarningWrapper;

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { fetchStatus } from 'soapbox/actions/statuses';
@ -16,12 +16,12 @@ interface IEmbeddedStatus {
},
}
const getStatus = makeGetStatus();
/** Status to be presented in an iframe for embeds on external websites. */
const EmbeddedStatus: React.FC<IEmbeddedStatus> = ({ params }) => {
const dispatch = useAppDispatch();
const history = useHistory();
const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector(state => getStatus(state, { id: params.statusId }));
const [loading, setLoading] = useState(true);

View File

@ -1,158 +0,0 @@
import debounce from 'lodash/debounce';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { fetchAccount, fetchAccountByUsername } from 'soapbox/actions/accounts';
import { fetchFavouritedStatuses, expandFavouritedStatuses, fetchAccountFavouritedStatuses, expandAccountFavouritedStatuses } from 'soapbox/actions/favourites';
import MissingIndicator from 'soapbox/components/missing_indicator';
import StatusList from 'soapbox/components/status_list';
import { Spinner } from 'soapbox/components/ui';
import { findAccountByUsername } from 'soapbox/selectors';
import { getFeatures } from 'soapbox/utils/features';
import Column from '../ui/components/column';
const messages = defineMessages({
heading: { id: 'column.favourited_statuses', defaultMessage: 'Liked posts' },
});
const mapStateToProps = (state, { params }) => {
const username = params.username || '';
const me = state.get('me');
const meUsername = state.getIn(['accounts', me, 'username'], '');
const isMyAccount = (username.toLowerCase() === meUsername.toLowerCase());
const features = getFeatures(state.get('instance'));
if (isMyAccount) {
return {
isMyAccount,
statusIds: state.status_lists.get('favourites').items,
isLoading: state.status_lists.get('favourites').isLoading,
hasMore: !!state.status_lists.get('favourites').next,
};
}
const accountFetchError = ((state.getIn(['accounts', -1, 'username']) || '').toLowerCase() === username.toLowerCase());
let accountId = -1;
if (accountFetchError) {
accountId = null;
} else {
const account = findAccountByUsername(state, username);
accountId = account ? account.getIn(['id'], null) : -1;
}
const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false);
const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible);
return {
isMyAccount,
accountId,
unavailable,
username,
isAccount: !!state.getIn(['accounts', accountId]),
statusIds: state.status_lists.get(`favourites:${accountId}`)?.items || [],
isLoading: state.status_lists.get(`favourites:${accountId}`)?.isLoading,
hasMore: !!state.status_lists.get(`favourites:${accountId}`)?.next,
};
};
export default @connect(mapStateToProps)
@injectIntl
class Favourites extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.orderedSet.isRequired,
intl: PropTypes.object.isRequired,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
isMyAccount: PropTypes.bool.isRequired,
};
componentDidMount() {
const { accountId, isMyAccount, username } = this.props;
if (isMyAccount)
this.props.dispatch(fetchFavouritedStatuses());
else {
if (accountId && accountId !== -1) {
this.props.dispatch(fetchAccount(accountId));
this.props.dispatch(fetchAccountFavouritedStatuses(accountId));
} else {
this.props.dispatch(fetchAccountByUsername(username));
}
}
}
componentDidUpdate(prevProps) {
const { accountId, isMyAccount } = this.props;
if (!isMyAccount && accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) {
this.props.dispatch(fetchAccount(accountId));
this.props.dispatch(fetchAccountFavouritedStatuses(accountId));
}
}
handleLoadMore = debounce(() => {
const { accountId, isMyAccount } = this.props;
if (isMyAccount) {
this.props.dispatch(expandFavouritedStatuses());
} else {
this.props.dispatch(expandAccountFavouritedStatuses(accountId));
}
}, 300, { leading: true })
render() {
const { intl, statusIds, isLoading, hasMore, isMyAccount, isAccount, accountId, unavailable } = this.props;
if (!isMyAccount && !isAccount && accountId !== -1) {
return (
<MissingIndicator />
);
}
if (accountId === -1) {
return (
<Column>
<Spinner />
</Column>
);
}
if (unavailable) {
return (
<Column>
<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
</div>
</Column>
);
}
const emptyMessage = isMyAccount
? <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any liked posts yet. When you like one, it will show up here." />
: <FormattedMessage id='empty_column.account_favourited_statuses' defaultMessage="This user doesn't have any liked posts yet." />;
return (
<Column label={intl.formatMessage(messages.heading)} withHeader={false} transparent>
<StatusList
statusIds={statusIds}
scrollKey='favourited_statuses'
hasMore={hasMore}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
/>
</Column>
);
}
}

View File

@ -0,0 +1,108 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchAccount, fetchAccountByUsername } from 'soapbox/actions/accounts';
import { fetchFavouritedStatuses, expandFavouritedStatuses, fetchAccountFavouritedStatuses, expandAccountFavouritedStatuses } from 'soapbox/actions/favourites';
import MissingIndicator from 'soapbox/components/missing_indicator';
import StatusList from 'soapbox/components/status_list';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { findAccountByUsername } from 'soapbox/selectors';
import Column from '../ui/components/column';
const messages = defineMessages({
heading: { id: 'column.favourited_statuses', defaultMessage: 'Liked posts' },
});
interface IFavourites {
params?: {
username?: string,
}
}
/** Timeline displaying a user's favourited statuses. */
const Favourites: React.FC<IFavourites> = (props) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const features = useFeatures();
const ownAccount = useOwnAccount();
const username = props.params?.username || '';
const account = useAppSelector(state => findAccountByUsername(state, username));
const isOwnAccount = username.toLowerCase() === ownAccount?.username?.toLowerCase();
const timelineKey = isOwnAccount ? 'favourites' : `favourites:${account?.id}`;
const statusIds = useAppSelector(state => state.status_lists.get(timelineKey)?.items || ImmutableOrderedSet<string>());
const isLoading = useAppSelector(state => state.status_lists.get(timelineKey)?.isLoading === true);
const hasMore = useAppSelector(state => !!state.status_lists.get(timelineKey)?.next);
const isUnavailable = useAppSelector(state => {
const blockedBy = state.relationships.getIn([account?.id, 'blocked_by']) === true;
return isOwnAccount ? false : (blockedBy && !features.blockersVisible);
});
const handleLoadMore = useCallback(debounce(() => {
if (isOwnAccount) {
dispatch(expandFavouritedStatuses());
} else if (account) {
dispatch(expandAccountFavouritedStatuses(account.id));
}
}, 300, { leading: true }), [account?.id]);
useEffect(() => {
if (isOwnAccount)
dispatch(fetchFavouritedStatuses());
else {
if (account) {
dispatch(fetchAccount(account.id));
dispatch(fetchAccountFavouritedStatuses(account.id));
} else {
dispatch(fetchAccountByUsername(username));
}
}
}, []);
useEffect(() => {
if (account && !isOwnAccount) {
dispatch(fetchAccount(account.id));
dispatch(fetchAccountFavouritedStatuses(account.id));
}
}, [account?.id]);
if (isUnavailable) {
return (
<Column>
<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
</div>
</Column>
);
}
if (!account) {
return (
<MissingIndicator />
);
}
const emptyMessage = isOwnAccount
? <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any liked posts yet. When you like one, it will show up here." />
: <FormattedMessage id='empty_column.account_favourited_statuses' defaultMessage="This user doesn't have any liked posts yet." />;
return (
<Column label={intl.formatMessage(messages.heading)} withHeader={false} transparent>
<StatusList
statusIds={statusIds}
scrollKey='favourited_statuses'
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
/>
</Column>
);
};
export default Favourites;

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
@ -16,8 +16,6 @@ const messages = defineMessages({
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
});
const getAccount = makeGetAccount();
interface IAccountAuthorize {
id: string,
}
@ -25,6 +23,8 @@ interface IAccountAuthorize {
const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
const intl = useIntl();
const dispatch = useDispatch();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, id));

View File

@ -1,138 +0,0 @@
import debounce from 'lodash/debounce';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import {
fetchAccount,
fetchFollowers,
expandFollowers,
fetchAccountByUsername,
} from 'soapbox/actions/accounts';
import MissingIndicator from 'soapbox/components/missing_indicator';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Spinner } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { findAccountByUsername } from 'soapbox/selectors';
import { getFollowDifference } from 'soapbox/utils/accounts';
import { getFeatures } from 'soapbox/utils/features';
import Column from '../ui/components/column';
const messages = defineMessages({
heading: { id: 'column.followers', defaultMessage: 'Followers' },
});
const mapStateToProps = (state, { params, withReplies = false }) => {
const username = params.username || '';
const me = state.get('me');
const accountFetchError = ((state.getIn(['accounts', -1, 'username']) || '').toLowerCase() === username.toLowerCase());
const features = getFeatures(state.get('instance'));
let accountId = -1;
if (accountFetchError) {
accountId = null;
} else {
const account = findAccountByUsername(state, username);
accountId = account ? account.getIn(['id'], null) : -1;
}
const diffCount = getFollowDifference(state, accountId, 'followers');
const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false);
const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible);
return {
accountId,
unavailable,
isAccount: !!state.getIn(['accounts', accountId]),
accountIds: state.user_lists.followers.get(accountId)?.items,
hasMore: !!state.user_lists.followers.get(accountId)?.next,
diffCount,
};
};
export default @connect(mapStateToProps)
@injectIntl
class Followers extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.orderedSet,
hasMore: PropTypes.bool,
diffCount: PropTypes.number,
isAccount: PropTypes.bool,
unavailable: PropTypes.bool,
};
componentDidMount() {
const { params: { username }, accountId } = this.props;
if (accountId && accountId !== -1) {
this.props.dispatch(fetchAccount(accountId));
this.props.dispatch(fetchFollowers(accountId));
} else {
this.props.dispatch(fetchAccountByUsername(username));
}
}
componentDidUpdate(prevProps) {
const { accountId, dispatch } = this.props;
if (accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) {
dispatch(fetchAccount(accountId));
dispatch(fetchFollowers(accountId));
}
}
handleLoadMore = debounce(() => {
if (this.props.accountId && this.props.accountId !== -1) {
this.props.dispatch(expandFollowers(this.props.accountId));
}
}, 300, { leading: true });
render() {
const { intl, accountIds, hasMore, diffCount, isAccount, accountId, unavailable } = this.props;
if (!isAccount && accountId !== -1) {
return (
<MissingIndicator />
);
}
if (accountId === -1 || (!accountIds)) {
return (
<Spinner />
);
}
if (unavailable) {
return (
<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
</div>
);
}
return (
<Column label={intl.formatMessage(messages.heading)} transparent>
<ScrollableList
scrollKey='followers'
hasMore={hasMore}
diffCount={diffCount}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />}
itemClassName='pb-4'
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} withNote={false} />,
)}
</ScrollableList>
</Column>
);
}
}

View File

@ -0,0 +1,115 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import {
fetchAccount,
fetchFollowers,
expandFollowers,
fetchAccountByUsername,
} from 'soapbox/actions/accounts';
import MissingIndicator from 'soapbox/components/missing_indicator';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Spinner } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { findAccountByUsername } from 'soapbox/selectors';
import Column from '../ui/components/column';
const messages = defineMessages({
heading: { id: 'column.followers', defaultMessage: 'Followers' },
});
interface IFollowers {
params?: {
username?: string,
}
}
/** Displays a list of accounts who follow the given account. */
const Followers: React.FC<IFollowers> = (props) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const features = useFeatures();
const ownAccount = useOwnAccount();
const [loading, setLoading] = useState(true);
const username = props.params?.username || '';
const account = useAppSelector(state => findAccountByUsername(state, username));
const isOwnAccount = username.toLowerCase() === ownAccount?.username?.toLowerCase();
const accountIds = useAppSelector(state => state.user_lists.followers.get(account!?.id)?.items || ImmutableOrderedSet<string>());
const hasMore = useAppSelector(state => !!state.user_lists.followers.get(account!?.id)?.next);
const isUnavailable = useAppSelector(state => {
const blockedBy = state.relationships.getIn([account?.id, 'blocked_by']) === true;
return isOwnAccount ? false : (blockedBy && !features.blockersVisible);
});
const handleLoadMore = useCallback(debounce(() => {
if (account) {
dispatch(expandFollowers(account.id));
}
}, 300, { leading: true }), [account?.id]);
useEffect(() => {
let promises = [];
if (account) {
promises = [
dispatch(fetchAccount(account.id)),
dispatch(fetchFollowers(account.id)),
];
} else {
promises = [
dispatch(fetchAccountByUsername(username)),
];
}
Promise.all(promises)
.then(() => setLoading(false))
.catch(() => setLoading(false));
}, [account?.id, username]);
if (loading && accountIds.isEmpty()) {
return (
<Spinner />
);
}
if (!account) {
return (
<MissingIndicator />
);
}
if (isUnavailable) {
return (
<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
</div>
);
}
return (
<Column label={intl.formatMessage(messages.heading)} transparent>
<ScrollableList
scrollKey='followers'
hasMore={hasMore}
onLoadMore={handleLoadMore}
emptyMessage={<FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />}
itemClassName='pb-4'
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} />,
)}
</ScrollableList>
</Column>
);
};
export default Followers;

View File

@ -1,138 +0,0 @@
import debounce from 'lodash/debounce';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import {
fetchAccount,
fetchFollowing,
expandFollowing,
fetchAccountByUsername,
} from 'soapbox/actions/accounts';
import MissingIndicator from 'soapbox/components/missing_indicator';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Spinner } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { findAccountByUsername } from 'soapbox/selectors';
import { getFollowDifference } from 'soapbox/utils/accounts';
import { getFeatures } from 'soapbox/utils/features';
import Column from '../ui/components/column';
const messages = defineMessages({
heading: { id: 'column.following', defaultMessage: 'Following' },
});
const mapStateToProps = (state, { params, withReplies = false }) => {
const username = params.username || '';
const me = state.get('me');
const accountFetchError = ((state.getIn(['accounts', -1, 'username']) || '').toLowerCase() === username.toLowerCase());
const features = getFeatures(state.get('instance'));
let accountId = -1;
if (accountFetchError) {
accountId = null;
} else {
const account = findAccountByUsername(state, username);
accountId = account ? account.getIn(['id'], null) : -1;
}
const diffCount = getFollowDifference(state, accountId, 'following');
const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false);
const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible);
return {
accountId,
unavailable,
isAccount: !!state.getIn(['accounts', accountId]),
accountIds: state.user_lists.following.get(accountId)?.items,
hasMore: !!state.user_lists.following.get(accountId)?.next,
diffCount,
};
};
export default @connect(mapStateToProps)
@injectIntl
class Following extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.orderedSet,
hasMore: PropTypes.bool,
isAccount: PropTypes.bool,
unavailable: PropTypes.bool,
diffCount: PropTypes.number,
};
componentDidMount() {
const { params: { username }, accountId } = this.props;
if (accountId && accountId !== -1) {
this.props.dispatch(fetchAccount(accountId));
this.props.dispatch(fetchFollowing(accountId));
} else {
this.props.dispatch(fetchAccountByUsername(username));
}
}
componentDidUpdate(prevProps) {
const { accountId, dispatch } = this.props;
if (accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) {
dispatch(fetchAccount(accountId));
dispatch(fetchFollowing(accountId));
}
}
handleLoadMore = debounce(() => {
if (this.props.accountId && this.props.accountId !== -1) {
this.props.dispatch(expandFollowing(this.props.accountId));
}
}, 300, { leading: true });
render() {
const { intl, accountIds, hasMore, isAccount, diffCount, accountId, unavailable } = this.props;
if (!isAccount && accountId !== -1) {
return (
<MissingIndicator />
);
}
if (accountId === -1 || (!accountIds)) {
return (
<Spinner />
);
}
if (unavailable) {
return (
<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
</div>
);
}
return (
<Column label={intl.formatMessage(messages.heading)} transparent>
<ScrollableList
scrollKey='following'
hasMore={hasMore}
diffCount={diffCount}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />}
itemClassName='pb-4'
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} withNote={false} />,
)}
</ScrollableList>
</Column>
);
}
}

View File

@ -0,0 +1,115 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import {
fetchAccount,
fetchFollowing,
expandFollowing,
fetchAccountByUsername,
} from 'soapbox/actions/accounts';
import MissingIndicator from 'soapbox/components/missing_indicator';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Spinner } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { findAccountByUsername } from 'soapbox/selectors';
import Column from '../ui/components/column';
const messages = defineMessages({
heading: { id: 'column.following', defaultMessage: 'Following' },
});
interface IFollowing {
params?: {
username?: string,
}
}
/** Displays a list of accounts the given user is following. */
const Following: React.FC<IFollowing> = (props) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const features = useFeatures();
const ownAccount = useOwnAccount();
const [loading, setLoading] = useState(true);
const username = props.params?.username || '';
const account = useAppSelector(state => findAccountByUsername(state, username));
const isOwnAccount = username.toLowerCase() === ownAccount?.username?.toLowerCase();
const accountIds = useAppSelector(state => state.user_lists.following.get(account!?.id)?.items || ImmutableOrderedSet<string>());
const hasMore = useAppSelector(state => !!state.user_lists.following.get(account!?.id)?.next);
const isUnavailable = useAppSelector(state => {
const blockedBy = state.relationships.getIn([account?.id, 'blocked_by']) === true;
return isOwnAccount ? false : (blockedBy && !features.blockersVisible);
});
const handleLoadMore = useCallback(debounce(() => {
if (account) {
dispatch(expandFollowing(account.id));
}
}, 300, { leading: true }), [account?.id]);
useEffect(() => {
let promises = [];
if (account) {
promises = [
dispatch(fetchAccount(account.id)),
dispatch(fetchFollowing(account.id)),
];
} else {
promises = [
dispatch(fetchAccountByUsername(username)),
];
}
Promise.all(promises)
.then(() => setLoading(false))
.catch(() => setLoading(false));
}, [account?.id, username]);
if (loading && accountIds.isEmpty()) {
return (
<Spinner />
);
}
if (!account) {
return (
<MissingIndicator />
);
}
if (isUnavailable) {
return (
<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
</div>
);
}
return (
<Column label={intl.formatMessage(messages.heading)} transparent>
<ScrollableList
scrollKey='following'
hasMore={hasMore}
onLoadMore={handleLoadMore}
emptyMessage={<FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />}
itemClassName='pb-4'
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} />,
)}
</ScrollableList>
</Column>
);
};
export default Following;

View File

@ -1,116 +0,0 @@
import classNames from 'clsx';
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { changeValue, submit, reset } from '../../../actions/group_editor';
const messages = defineMessages({
title: { id: 'groups.form.title', defaultMessage: 'Enter a new group title' },
description: { id: 'groups.form.description', defaultMessage: 'Enter the group description' },
coverImage: { id: 'groups.form.coverImage', defaultMessage: 'Upload a banner image' },
coverImageChange: { id: 'groups.form.coverImageChange', defaultMessage: 'Banner image selected' },
create: { id: 'groups.form.create', defaultMessage: 'Create group' },
});
const mapStateToProps = state => ({
title: state.getIn(['group_editor', 'title']),
description: state.getIn(['group_editor', 'description']),
coverImage: state.getIn(['group_editor', 'coverImage']),
disabled: state.getIn(['group_editor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
onTitleChange: value => dispatch(changeValue('title', value)),
onDescriptionChange: value => dispatch(changeValue('description', value)),
onCoverImageChange: value => dispatch(changeValue('coverImage', value)),
onSubmit: routerHistory => dispatch(submit(routerHistory)),
reset: () => dispatch(reset()),
});
export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
@withRouter
class Create extends React.PureComponent {
static propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
coverImage: PropTypes.object,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onTitleChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
reset: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired,
onCoverImageChange: PropTypes.func.isRequired,
history: PropTypes.object,
};
constructor(props) {
super(props);
props.reset();
}
handleTitleChange = e => {
this.props.onTitleChange(e.target.value);
}
handleDescriptionChange = e => {
this.props.onDescriptionChange(e.target.value);
}
handleCoverImageChange = e => {
this.props.onCoverImageChange(e.target.files[0]);
}
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit(this.props.history);
}
render() {
const { title, description, coverImage, disabled, intl } = this.props;
return (
<form className='group-form' method='post' onSubmit={this.handleSubmit}>
<div>
<input
className='standard'
type='text'
value={title}
disabled={disabled}
onChange={this.handleTitleChange}
placeholder={intl.formatMessage(messages.title)}
/>
</div>
<div>
<textarea
className='standard'
type='text'
value={description}
disabled={disabled}
onChange={this.handleDescriptionChange}
placeholder={intl.formatMessage(messages.description)}
/>
</div>
<div>
<label htmlFor='group_cover_image' className={classNames('group-form__file-label', { 'group-form__file-label--selected': coverImage !== null })}>
{intl.formatMessage(coverImage === null ? messages.coverImage : messages.coverImageChange)}
</label>
<input
type='file'
className='group-form__file'
id='group_cover_image'
disabled={disabled}
onChange={this.handleCoverImageChange}
/>
<button className='standard-small'>{intl.formatMessage(messages.create)}</button>
</div>
</form>
);
}
}

View File

@ -1,148 +0,0 @@
import classNames from 'clsx';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import MissingIndicator from 'soapbox/components/missing_indicator';
import { Column, Spinner } from 'soapbox/components/ui';
import { changeValue, submit, setUp } from '../../../actions/group_editor';
const messages = defineMessages({
title: { id: 'groups.form.title', defaultMessage: 'Title' },
description: { id: 'groups.form.description', defaultMessage: 'Description' },
coverImage: { id: 'groups.form.coverImage', defaultMessage: 'Upload new banner image (optional)' },
coverImageChange: { id: 'groups.form.coverImageChange', defaultMessage: 'Banner image selected' },
update: { id: 'groups.form.update', defaultMessage: 'Update group' },
});
const mapStateToProps = (state, props) => ({
group: state.getIn(['groups', props.params.id]),
title: state.getIn(['group_editor', 'title']),
description: state.getIn(['group_editor', 'description']),
coverImage: state.getIn(['group_editor', 'coverImage']),
disabled: state.getIn(['group_editor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
onTitleChange: value => dispatch(changeValue('title', value)),
onDescriptionChange: value => dispatch(changeValue('description', value)),
onCoverImageChange: value => dispatch(changeValue('coverImage', value)),
onSubmit: routerHistory => dispatch(submit(routerHistory)),
setUp: group => dispatch(setUp(group)),
});
export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
@withRouter
class Edit extends React.PureComponent {
static propTypes = {
group: ImmutablePropTypes.map,
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
coverImage: PropTypes.object,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onTitleChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired,
onCoverImageChange: PropTypes.func.isRequired,
setUp: PropTypes.func.isRequired,
history: PropTypes.object,
};
constructor(props) {
super(props);
if (props.group) props.setUp(props.group);
}
componentDidUpdate(prevProps) {
if (!prevProps.group && this.props.group) {
this.props.setUp(this.props.group);
}
}
handleTitleChange = e => {
this.props.onTitleChange(e.target.value);
}
handleDescriptionChange = e => {
this.props.onDescriptionChange(e.target.value);
}
handleCoverImageChange = e => {
this.props.onCoverImageChange(e.target.files[0]);
}
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit(this.props.history);
}
handleClick = () => {
this.props.onSubmit(this.props.history);
}
render() {
const { group, title, description, coverImage, disabled, intl } = this.props;
if (typeof group === 'undefined') {
return (
<Column>
<Spinner />
</Column>
);
} else if (group === false) {
return (
<MissingIndicator />
);
}
return (
<form className='group-form' method='post' onSubmit={this.handleSubmit}>
<div>
<input
className='standard'
type='text'
value={title}
disabled={disabled}
onChange={this.handleTitleChange}
placeholder={intl.formatMessage(messages.title)}
/>
</div>
<div>
<textarea
className='standard'
type='text'
value={description}
disabled={disabled}
onChange={this.handleDescriptionChange}
placeholder={intl.formatMessage(messages.description)}
/>
</div>
<div>
<label htmlFor='group_cover_image' className={classNames('group-form__file-label', { 'group-form__file-label--selected': coverImage !== null })}>
{intl.formatMessage(coverImage === null ? messages.coverImage : messages.coverImageChange)}
</label>
<input
type='file'
className='group-form__file'
id='group_cover_image'
disabled={disabled}
onChange={this.handleCoverImageChange}
/>
<button>{intl.formatMessage(messages.update)}</button>
</div>
</form>
);
}
}

View File

@ -1,57 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { shortNumberFormat } from '../../../utils/numbers';
const messages = defineMessages({
members: { id: 'groups.card.members', defaultMessage: 'Members' },
view: { id: 'groups.card.view', defaultMessage: 'View' },
join: { id: 'groups.card.join', defaultMessage: 'Join' },
role_member: { id: 'groups.card.roles.member', defaultMessage: 'You\'re a member' },
role_admin: { id: 'groups.card.roles.admin', defaultMessage: 'You\'re an admin' },
});
const mapStateToProps = (state, { id }) => ({
group: state.getIn(['groups', id]),
relationships: state.getIn(['group_relationships', id]),
});
export default @connect(mapStateToProps)
@injectIntl
class GroupCard extends ImmutablePureComponent {
static propTypes = {
group: ImmutablePropTypes.map,
relationships: ImmutablePropTypes.map,
}
getRole() {
const { intl, relationships } = this.props;
if (relationships.get('admin')) return intl.formatMessage(messages.role_admin);
if (relationships.get('member')) return intl.formatMessage(messages.role_member);
return null;
}
render() {
const { intl, group } = this.props;
const coverImageUrl = group.get('cover_image_url');
const role = this.getRole();
return (
<Link to={`/groups/${group.get('id')}`} className='group-card'>
<div className='group-card__header'>{coverImageUrl && <img alt='' src={coverImageUrl} />}</div>
<div className='group-card__content'>
<div className='group-card__title'>{group.get('title')}</div>
<div className='group-card__meta'><strong>{shortNumberFormat(group.get('member_count'))}</strong> {intl.formatMessage(messages.members)}{role && <span> · {role}</span>}</div>
<div className='group-card__description'>{group.get('description')}</div>
</div>
</Link>
);
}
}

View File

@ -1,93 +0,0 @@
import classNames from 'clsx';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { fetchGroups } from '../../../actions/groups';
import GroupCreate from '../create';
import GroupCard from './card';
const messages = defineMessages({
heading: { id: 'column.groups', defaultMessage: 'Groups' },
create: { id: 'groups.create', defaultMessage: 'Create group' },
tab_featured: { id: 'groups.tab_featured', defaultMessage: 'Featured' },
tab_member: { id: 'groups.tab_member', defaultMessage: 'Member' },
tab_admin: { id: 'groups.tab_admin', defaultMessage: 'Manage' },
});
const mapStateToProps = (state, { activeTab }) => ({
groupIds: state.getIn(['group_lists', activeTab]),
});
export default @connect(mapStateToProps)
@injectIntl
class Groups extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
activeTab: PropTypes.string.isRequired,
showCreateForm: PropTypes.bool,
dispatch: PropTypes.func.isRequired,
groups: ImmutablePropTypes.map,
groupIds: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
};
componentDidMount() {
this.props.dispatch(fetchGroups(this.props.activeTab));
}
componentDidUpdate(oldProps) {
if (this.props.activeTab && this.props.activeTab !== oldProps.activeTab) {
this.props.dispatch(fetchGroups(this.props.activeTab));
}
}
renderHeader() {
const { intl, activeTab } = this.props;
return (
<div className='group-column-header'>
<div className='group-column-header__cta'><Link to='/groups/create' className='button standard-small'>{intl.formatMessage(messages.create)}</Link></div>
<div className='group-column-header__title'>{intl.formatMessage(messages.heading)}</div>
<div className='column-header__wrapper'>
<h1 className='column-header'>
<Link to='/groups' className={classNames('btn grouped', { 'active': 'featured' === activeTab })}>
{intl.formatMessage(messages.tab_featured)}
</Link>
<Link to='/groups/browse/member' className={classNames('btn grouped', { 'active': 'member' === activeTab })}>
{intl.formatMessage(messages.tab_member)}
</Link>
<Link to='/groups/browse/admin' className={classNames('btn grouped', { 'active': 'admin' === activeTab })}>
{intl.formatMessage(messages.tab_admin)}
</Link>
</h1>
</div>
</div>
);
}
render() {
const { groupIds, showCreateForm } = this.props;
return (
<div>
{!showCreateForm && this.renderHeader()}
{showCreateForm && <GroupCreate /> }
<div className='group-card-list'>
{groupIds.map(id => <GroupCard key={id} id={id} />)}
</div>
</div>
);
}
}

View File

@ -1,76 +0,0 @@
import debounce from 'lodash/debounce';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { Spinner } from 'soapbox/components/ui';
import {
fetchMembers,
expandMembers,
} from '../../../actions/groups';
import ScrollableList from '../../../components/scrollable_list';
import AccountContainer from '../../../containers/account_container';
import Column from '../../ui/components/column';
const mapStateToProps = (state, { params: { id } }) => ({
group: state.getIn(['groups', id]),
accountIds: state.user_lists.groups.get(id)?.items,
hasMore: !!state.user_lists.groups.get(id)?.next,
});
export default @connect(mapStateToProps)
class GroupMembers extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.orderedSet,
hasMore: PropTypes.bool,
};
componentDidMount() {
const { params: { id } } = this.props;
this.props.dispatch(fetchMembers(id));
}
componentDidUpdate(prevProps) {
if (this.props.params.id !== prevProps.params.id) {
this.props.dispatch(fetchMembers(this.props.params.id));
}
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandMembers(this.props.params.id));
}, 300, { leading: true });
render() {
const { accountIds, hasMore, group } = this.props;
if (!group || !accountIds) {
return (
<Column>
<Spinner />
</Column>
);
}
return (
<Column>
<ScrollableList
scrollKey='members'
hasMore={hasMore}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='group.members.empty' defaultMessage='This group does not has any members.' />}
>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</ScrollableList>
</Column>
);
}
}

View File

@ -1,94 +0,0 @@
import debounce from 'lodash/debounce';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { Spinner } from 'soapbox/components/ui';
import {
fetchRemovedAccounts,
expandRemovedAccounts,
removeRemovedAccount,
} from '../../../actions/groups';
import ScrollableList from '../../../components/scrollable_list';
import AccountContainer from '../../../containers/account_container';
import Column from '../../ui/components/column';
const messages = defineMessages({
remove: { id: 'groups.removed_accounts', defaultMessage: 'Allow joining' },
});
const mapStateToProps = (state, { params: { id } }) => ({
group: state.getIn(['groups', id]),
accountIds: state.user_lists.groups_removed_accounts.get(id)?.items,
hasMore: !!state.user_lists.groups_removed_accounts.get(id)?.next,
});
export default @connect(mapStateToProps)
@injectIntl
class GroupRemovedAccounts extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.orderedSet,
hasMore: PropTypes.bool,
};
componentDidMount() {
const { params: { id } } = this.props;
this.props.dispatch(fetchRemovedAccounts(id));
}
componentDidUpdate(prevProps) {
if (this.props.params.id !== prevProps.params.id) {
this.props.dispatch(fetchRemovedAccounts(this.props.params.id));
}
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandRemovedAccounts(this.props.params.id));
}, 300, { leading: true });
handleOnActionClick = (group, id) => {
return () => {
this.props.dispatch(removeRemovedAccount(group.get('id'), id));
};
}
render() {
const { accountIds, hasMore, group, intl } = this.props;
if (!group || !accountIds) {
return (
<Column>
<Spinner />
</Column>
);
}
return (
<Column>
<ScrollableList
scrollKey='removed_accounts'
hasMore={hasMore}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='group.removed_accounts.empty' defaultMessage='This group does not has any removed accounts.' />}
>
{accountIds.map(id => (<AccountContainer
key={id}
id={id}
actionIcon={require('@tabler/icons/x.svg')}
onActionClick={this.handleOnActionClick(group, id)}
actionTitle={intl.formatMessage(messages.remove)}
/>))}
</ScrollableList>
</Column>
);
}
}

View File

@ -1,53 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import Icon from 'soapbox/components/icon';
import Item from './item';
const messages = defineMessages({
title: { id: 'groups.sidebar-panel.title', defaultMessage: 'Groups You\'re In' },
show_all: { id: 'groups.sidebar-panel.show_all', defaultMessage: 'Show all' },
});
const mapStateToProps = (state, { id }) => ({
groupIds: state.getIn(['group_lists', 'member']),
});
export default @connect(mapStateToProps)
@injectIntl
class GroupSidebarPanel extends ImmutablePureComponent {
static propTypes = {
groupIds: ImmutablePropTypes.list,
}
render() {
const { intl, groupIds } = this.props;
const count = groupIds.count();
// Only when there are groups to show
if (count === 0) return null;
return (
<div className='wtf-panel group-sidebar-panel'>
<div className='wtf-panel-header'>
<Icon id='users' className='wtf-panel-header__icon' />
<span className='wtf-panel-header__label'>{intl.formatMessage(messages.title)}</span>
</div>
<div className='wtf-panel__content'>
<div className='group-sidebar-panel__items'>
{groupIds.slice(0, 10).map(groupId => <Item key={groupId} id={groupId} />)}
{count > 10 && <Link className='group-sidebar-panel__items__show-all' to='/groups/browse/member'>{intl.formatMessage(messages.show_all)}</Link>}
</div>
</div>
</div>
);
}
}

View File

@ -1,48 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { shortNumberFormat } from '../../../utils/numbers';
const messages = defineMessages({
new_statuses: { id: 'groups.sidebar-panel.item.view', defaultMessage: 'new posts' },
no_recent_activity: { id: 'groups.sidebar-panel.item.no_recent_activity', defaultMessage: 'No recent activity' },
});
const mapStateToProps = (state, { id }) => ({
group: state.getIn(['groups', id]),
relationships: state.getIn(['group_relationships', id]),
});
export default @connect(mapStateToProps)
@injectIntl
class Item extends ImmutablePureComponent {
static propTypes = {
group: ImmutablePropTypes.map,
relationships: ImmutablePropTypes.map,
}
render() {
const { intl, group, relationships } = this.props;
// Wait for relationships
if (!relationships) return null;
const unreadCount = relationships.get('unread_count');
return (
<Link to={`/groups/${group.get('id')}`} className='group-sidebar-panel__item'>
<div className='group-sidebar-panel__item__title'>{group.get('title')}</div>
<div className='group-sidebar-panel__item__meta'>
{unreadCount > 0 && <span className='group-sidebar-panel__item__meta__unread'>{shortNumberFormat(unreadCount)} {intl.formatMessage(messages.new_statuses)}</span>}
{unreadCount === 0 && <span>{intl.formatMessage(messages.no_recent_activity)}</span>}
</div>
</Link>
);
}
}

View File

@ -1,92 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import { NavLink } from 'react-router-dom';
import { Button } from 'soapbox/components/ui';
import DropdownMenuContainer from '../../../../containers/dropdown_menu_container';
const messages = defineMessages({
join: { id: 'groups.join', defaultMessage: 'Join group' },
leave: { id: 'groups.leave', defaultMessage: 'Leave group' },
removed_accounts: { id: 'groups.removed_accounts', defaultMessage: 'Removed Accounts' },
edit: { id: 'groups.edit', defaultMessage: 'Edit' },
});
export default @injectIntl
class Header extends ImmutablePureComponent {
static propTypes = {
group: ImmutablePropTypes.map,
relationships: ImmutablePropTypes.map,
toggleMembership: PropTypes.func.isRequired,
};
toggle = () => {
const { group, relationships, toggleMembership } = this.props;
toggleMembership(group, relationships);
}
getActionButton() {
const { group, relationships, intl } = this.props;
if (!relationships) {
return '';
} else if (!relationships.get('member')) {
return <Button className='logo-button' text={intl.formatMessage(messages.join)} onClick={this.toggle} />;
} else if (relationships.get('member')) {
return <Button className='logo-button' text={intl.formatMessage(messages.leave, { name: group.get('title') })} onClick={this.toggle} />;
}
return '';
}
getAdminMenu() {
const { group, intl } = this.props;
const menu = [
{
text: intl.formatMessage(messages.edit),
to: `/groups/${group.get('id')}/edit`,
icon: require('@tabler/icons/edit.svg'),
},
{
text: intl.formatMessage(messages.removed_accounts),
to: `/groups/${group.get('id')}/removed_accounts`,
icon: require('@tabler/icons/trash.svg'),
destructive: true,
},
];
return <DropdownMenuContainer items={menu} src={require('@tabler/icons/dots-vertical.svg')} direction='right' />;
}
render() {
const { group, relationships } = this.props;
if (!group || !relationships) {
return null;
}
return (
<div className='group__header-container'>
<div className='group__header'>
<div className='group__cover'>
<img src={group.get('cover_image_url')} alt='' className='parallax' />
</div>
<div className='group__tabs'>
<NavLink exact className='group__tabs__tab' activeClassName='group__tabs__tab--active' to={`/groups/${group.get('id')}`}>Posts</NavLink>
<NavLink exact className='group__tabs__tab' activeClassName='group__tabs__tab--active' to={`/groups/${group.get('id')}/members`}>Members</NavLink>
{this.getActionButton()}
{relationships.get('admin') && this.getAdminMenu()}
</div>
</div>
</div>
);
}
}

View File

@ -1,38 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl, defineMessages } from 'react-intl';
import Icon from 'soapbox/components/icon';
const messages = defineMessages({
group_archived: { id: 'group.detail.archived_group', defaultMessage: 'Archived group' },
group_admin: { id: 'groups.detail.role_admin', defaultMessage: 'You\'re an admin' },
});
export default @injectIntl
class GroupPanel extends ImmutablePureComponent {
static propTypes = {
group: ImmutablePropTypes.map,
relationships: ImmutablePropTypes.map,
}
render() {
const { group, relationships, intl } = this.props;
return (
<div className='group__panel'>
<h1 className='group__panel__title'>
{group.get('title')}
{group.get('archived') && <Icon id='lock' title={intl.formatMessage(messages.group_archived)} />}
</h1>
{relationships.get('admin') && <span className='group__panel__label'>{intl.formatMessage(messages.group_admin)}</span>}
<div className='group__panel__description'>{group.get('description')}</div>
</div>
);
}
}

View File

@ -1,21 +0,0 @@
import { connect } from 'react-redux';
import { joinGroup, leaveGroup } from '../../../../actions/groups';
import Header from '../components/header';
const mapStateToProps = (state, { groupId }) => ({
group: state.getIn(['groups', groupId]),
relationships: state.getIn(['group_relationships', groupId]),
});
const mapDispatchToProps = (dispatch, { intl }) => ({
toggleMembership(group, relationships) {
if (relationships.get('member')) {
dispatch(leaveGroup(group.get('id')));
} else {
dispatch(joinGroup(group.get('id')));
}
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Header);

View File

@ -1,107 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { Column, Spinner } from 'soapbox/components/ui';
import ComposeFormContainer from '../../../../soapbox/features/compose/containers/compose_form_container';
import { connectGroupStream } from '../../../actions/streaming';
import { expandGroupTimeline } from '../../../actions/timelines';
import Avatar from '../../../components/avatar';
import MissingIndicator from '../../../components/missing_indicator';
import Timeline from '../../ui/components/timeline';
const mapStateToProps = (state, props) => {
const me = state.get('me');
return {
account: state.getIn(['accounts', me]),
group: state.getIn(['groups', props.params.id]),
relationships: state.getIn(['group_relationships', props.params.id]),
hasUnread: state.getIn(['timelines', `group:${props.params.id}`, 'unread']) > 0,
};
};
export default @connect(mapStateToProps)
@injectIntl
class GroupTimeline extends React.PureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
hasUnread: PropTypes.bool,
group: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
relationships: ImmutablePropTypes.map,
account: ImmutablePropTypes.record,
intl: PropTypes.object.isRequired,
};
componentDidMount() {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(expandGroupTimeline(id));
this.disconnect = dispatch(connectGroupStream(id));
}
componentWillUnmount() {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
handleLoadMore = maxId => {
const { id } = this.props.params;
this.props.dispatch(expandGroupTimeline(id, { maxId }));
}
render() {
const { columnId, group, relationships, account } = this.props;
const { id } = this.props.params;
if (typeof group === 'undefined' || !relationships) {
return (
<Column>
<Spinner />
</Column>
);
} else if (group === false) {
return (
<MissingIndicator />
);
}
const acct = account ? account.get('acct') : '';
return (
<div>
{relationships.get('member') && (
<div className='timeline-compose-block'>
<Link className='timeline-compose-block__avatar' to={`/@${acct}`}>
<Avatar account={account} size={46} />
</Link>
<ComposeFormContainer group={group} shouldCondense autoFocus={false} />
</div>
)}
<div className='group__feed'>
<Timeline
alwaysPrepend
scrollKey={`group_timeline-${columnId}`}
timelineId={`group:${id}`}
onLoadMore={this.handleLoadMore}
group={group}
withGroupAdmin={relationships && relationships.get('admin')}
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There is nothing in this group yet. When members of this group make new posts, they will appear here.' />}
/>
</div>
</div>
);
}
}

View File

@ -1,172 +0,0 @@
import classNames from 'clsx';
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import ReactSwipeableViews from 'react-swipeable-views';
import { closeOnboarding } from '../../actions/onboarding';
const FrameWelcome = ({ domain, onNext }) => (
<div className='introduction__frame'>
<div className='introduction__text introduction__text--centered'>
<h3><FormattedMessage id='introduction.welcome.headline' defaultMessage='First steps' /></h3>
<p><FormattedMessage id='introduction.welcome.text' defaultMessage="Welcome to the fediverse! In a few moments, you'll be able to broadcast messages and talk to your friends across a wide variety of servers. But this server, {domain}, is special—it hosts your profile, so remember its name." values={{ domain: <code>{domain}</code> }} /></p>
</div>
<div className='introduction__action'>
<button className='button' onClick={onNext}><FormattedMessage id='introduction.welcome.action' defaultMessage="Let's go!" /></button>
</div>
</div>
);
FrameWelcome.propTypes = {
domain: PropTypes.string.isRequired,
onNext: PropTypes.func.isRequired,
};
const FrameFederation = ({ onNext }) => (
<div className='introduction__frame'>
<div className='introduction__text introduction__text--columnized'>
<div>
<h3><FormattedMessage id='introduction.federation.home.headline' defaultMessage='Home' /></h3>
<p><FormattedMessage id='introduction.federation.home.text' defaultMessage='Posts from people you follow will appear in your home feed. You can follow anyone on any server!' /></p>
</div>
</div>
<div className='introduction__action'>
<button className='button' onClick={onNext}><FormattedMessage id='introduction.federation.action' defaultMessage='Next' /></button>
</div>
</div>
);
FrameFederation.propTypes = {
onNext: PropTypes.func.isRequired,
};
const FrameInteractions = ({ onNext }) => (
<div className='introduction__frame'>
<div className='introduction__text introduction__text--columnized'>
<div>
<h3><FormattedMessage id='introduction.interactions.reply.headline' defaultMessage='Reply' /></h3>
<p><FormattedMessage id='introduction.interactions.reply.text' defaultMessage="You can reply to other people's and your own posts, which will chain them together in a conversation." /></p>
</div>
<div>
<h3><FormattedMessage id='introduction.interactions.reblog.headline' defaultMessage='Repost' /></h3>
<p><FormattedMessage id='introduction.interactions.reblog.text' defaultMessage="You can share other people's posts with your followers by reposting them." /></p>
</div>
<div>
<h3><FormattedMessage id='introduction.interactions.favourite.headline' defaultMessage='Favorite' /></h3>
<p><FormattedMessage id='introduction.interactions.favourite.text' defaultMessage='You can save a post for later, and let the author know that you liked it, by favoriting it.' /></p>
</div>
</div>
<div className='introduction__action'>
<button className='button' onClick={onNext}><FormattedMessage id='introduction.interactions.action' defaultMessage='Finish tutorial!' /></button>
</div>
</div>
);
FrameInteractions.propTypes = {
onNext: PropTypes.func.isRequired,
};
export default @connect(state => ({ domain: state.getIn(['meta', 'domain']) }))
class Introduction extends React.PureComponent {
static propTypes = {
domain: PropTypes.string.isRequired,
dispatch: PropTypes.func.isRequired,
};
state = {
currentIndex: 0,
};
constructor(props) {
super(props);
this.pages = [
<FrameWelcome domain={props.domain} onNext={this.handleNext} />,
<FrameFederation onNext={this.handleNext} />,
<FrameInteractions onNext={this.handleFinish} />,
];
}
componentDidMount() {
window.addEventListener('keyup', this.handleKeyUp);
}
componentWillUnmount() {
window.addEventListener('keyup', this.handleKeyUp);
}
handleDot = (e) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
this.setState({ currentIndex: i });
}
handlePrev = () => {
this.setState(({ currentIndex }) => ({
currentIndex: Math.max(0, currentIndex - 1),
}));
}
handleNext = () => {
const { pages } = this;
this.setState(({ currentIndex }) => ({
currentIndex: Math.min(currentIndex + 1, pages.length - 1),
}));
}
handleSwipe = (index) => {
this.setState({ currentIndex: index });
}
handleFinish = () => {
this.props.dispatch(closeOnboarding());
}
handleKeyUp = ({ key }) => {
switch (key) {
case 'ArrowLeft':
this.handlePrev();
break;
case 'ArrowRight':
this.handleNext();
break;
}
}
render() {
const { currentIndex } = this.state;
const { pages } = this;
return (
<div className='introduction'>
<ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} className='introduction__pager'>
{pages.map((page, i) => (
<div key={i} className={classNames('introduction__frame-wrapper', { 'active': i === currentIndex })}>{page}</div>
))}
</ReactSwipeableViews>
<div className='introduction__dots'>
{pages.map((_, i) => (
<div
key={`dot-${i}`}
role='button'
tabIndex='0'
data-index={i}
onClick={this.handleDot}
className={classNames('introduction__dot', { active: i === currentIndex })}
/>
))}
</div>
</div>
);
}
}

View File

@ -113,7 +113,10 @@ const LandingPage = () => {
</h1>
<Text size='lg'>
{instance.description}
<span
className='instance-description'
dangerouslySetInnerHTML={{ __html: instance.short_description || instance.description }}
/>
</Text>
</Stack>
</div>

View File

@ -1,17 +1,17 @@
import React from 'react';
import React, { useCallback } from 'react';
import DisplayName from 'soapbox/components/display-name';
import { Avatar } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
const getAccount = makeGetAccount();
interface IAccount {
accountId: string,
}
const Account: React.FC<IAccount> = ({ accountId }) => {
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, accountId));
if (!account) return null;

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { removeFromListEditor, addToListEditor } from 'soapbox/actions/lists';
@ -13,8 +13,6 @@ const messages = defineMessages({
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
});
const getAccount = makeGetAccount();
interface IAccount {
accountId: string,
}
@ -22,6 +20,7 @@ interface IAccount {
const Account: React.FC<IAccount> = ({ accountId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, accountId));
const isAdded = useAppSelector((state) => state.listEditor.accounts.items.includes(accountId));

View File

@ -20,8 +20,6 @@ import { NotificationType, validType } from 'soapbox/utils/notification';
import type { ScrollPosition } from 'soapbox/components/status';
import type { Account, Status as StatusEntity, Notification as NotificationEntity } from 'soapbox/types/entities';
const getNotification = makeGetNotification();
const notificationForScreenReader = (intl: IntlShape, message: string, timestamp: Date) => {
const output = [message];
@ -153,6 +151,8 @@ const Notification: React.FC<INotificaton> = (props) => {
const dispatch = useAppDispatch();
const getNotification = useCallback(makeGetNotification(), []);
const notification = useAppSelector((state) => getNotification(state, props.notification));
const history = useHistory();

View File

@ -10,7 +10,6 @@ import useOnboardingSuggestions from 'soapbox/queries/suggestions';
const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
const { data, fetchNextPage, hasNextPage, isFetching } = useOnboardingSuggestions();
const handleLoadMore = debounce(() => {
if (isFetching) {
return null;

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