Merge remote-tracking branch 'origin/develop' into chats
This commit is contained in:
commit
d7243c0e91
|
@ -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);
|
||||
|
|
|
@ -121,6 +121,7 @@ describe('deleteStatus()', () => {
|
|||
version: '0.0.0',
|
||||
},
|
||||
withRedraft: true,
|
||||
id: 'compose-modal',
|
||||
},
|
||||
{ type: 'MODAL_OPEN', modalType: 'COMPOSE', modalProps: undefined },
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -177,7 +177,6 @@ const toggleFavourite = (status: StatusEntity) =>
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const favouriteRequest = (status: StatusEntity) => ({
|
||||
type: FAVOURITE_REQUEST,
|
||||
status: status,
|
||||
|
|
|
@ -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 = (<>
|
||||
const message = (
|
||||
<Stack space={4}>
|
||||
<OutlineBox>
|
||||
<AccountContainer id={accountId} />
|
||||
</OutlineBox>
|
||||
|
||||
<Text>
|
||||
{intl.formatMessage(messages.deleteUserPrompt, { acct })}
|
||||
</>);
|
||||
|
||||
const confirm = (<>
|
||||
{favicon &&
|
||||
<div className='submit__favicon'>
|
||||
<img src={favicon} alt='' />
|
||||
</div>}
|
||||
{intl.formatMessage(messages.deleteUserConfirm, { name })}
|
||||
</>);
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const confirm = intl.formatMessage(messages.deleteUserConfirm, { name });
|
||||
const checkbox = local ? intl.formatMessage(messages.deleteLocalUserCheckbox) : false;
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
|
|
|
@ -11,6 +11,7 @@ import type { APIEntity } from 'soapbox/types/entities';
|
|||
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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }) => (
|
||||
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-yellow-500 text-white': slug === 'donor',
|
||||
'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': slug === 'bot',
|
||||
'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;
|
||||
|
|
|
@ -34,7 +34,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
|
|||
id: domId,
|
||||
className: classNames({
|
||||
'w-auto': isSelect,
|
||||
}),
|
||||
}, child.props.className),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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) => ({
|
||||
|
|
|
@ -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;
|
|
@ -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) && (
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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,12 +125,14 @@ 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,
|
||||
})}
|
||||
>
|
||||
<Stack
|
||||
space={2}
|
||||
onClick={handleExpandClick}
|
||||
>
|
||||
<AccountContainer
|
||||
|
@ -155,6 +159,7 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
|
|||
onToggleVisibility={handleToggleMediaVisibility}
|
||||
/>
|
||||
</Stack>
|
||||
</OutlineBox>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
} 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));
|
||||
}
|
||||
});
|
||||
} 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);
|
||||
|
||||
if (isAdmin) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.admin_account, { name: username }),
|
||||
href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`,
|
||||
text: intl.formatMessage(messages.adminAccount, { name: username }),
|
||||
action: onModerate,
|
||||
icon: require('@tabler/icons/gavel.svg'),
|
||||
action: (event) => event.stopPropagation(),
|
||||
});
|
||||
|
||||
if (isAdmin) {
|
||||
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,
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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) {
|
||||
|
|
|
@ -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,109 +431,13 @@ 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,
|
||||
text: intl.formatMessage(messages.adminAccount, { name: account.username }),
|
||||
action: onModerate,
|
||||
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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return menu;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
@ -26,6 +24,8 @@ 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) => {
|
||||
const instance = state.instance;
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,8 +36,8 @@ interface IOption {
|
|||
title: string
|
||||
}
|
||||
|
||||
const Option = (props: IOption) => {
|
||||
const {
|
||||
const Option: React.FC<IOption> = ({
|
||||
composeId,
|
||||
index,
|
||||
maxChars,
|
||||
numOptions,
|
||||
|
@ -44,12 +45,11 @@ const Option = (props: IOption) => {
|
|||
onRemove,
|
||||
onRemovePoll,
|
||||
title,
|
||||
} = props;
|
||||
|
||||
}) => {
|
||||
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}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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));
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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());
|
||||
|
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
|
@ -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));
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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,
|
||||
}
|
||||
|
@ -26,6 +24,8 @@ const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
|
|||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, id));
|
||||
|
||||
const onAuthorize = () => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue