diff --git a/app/soapbox/actions/__tests__/compose.test.ts b/app/soapbox/actions/__tests__/compose.test.ts index 88f8c8858..1579d63c9 100644 --- a/app/soapbox/actions/__tests__/compose.test.ts +++ b/app/soapbox/actions/__tests__/compose.test.ts @@ -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); diff --git a/app/soapbox/actions/__tests__/statuses.test.ts b/app/soapbox/actions/__tests__/statuses.test.ts index 18cbc173b..68af7608f 100644 --- a/app/soapbox/actions/__tests__/statuses.test.ts +++ b/app/soapbox/actions/__tests__/statuses.test.ts @@ -121,6 +121,7 @@ describe('deleteStatus()', () => { version: '0.0.0', }, withRedraft: true, + id: 'compose-modal', }, { type: 'MODAL_OPEN', modalType: 'COMPOSE', modalProps: undefined }, ]; diff --git a/app/soapbox/actions/admin.ts b/app/soapbox/actions/admin.ts index 660b52dce..02af5e87a 100644 --- a/app/soapbox/actions/admin.ts +++ b/app/soapbox/actions/admin.ts @@ -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, }; diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index 53209464e..d592d9cc3 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -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)); diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 804141cd8..d397f4026 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -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) => +const changeUploadCompose = (composeId: string, id: string, params: Record) => (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) => +const selectComposeSuggestion = (composeId: string, position: number, token: string | null, suggestion: AutoSuggestion, path: Array) => (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) => ({ +const updateSuggestionTags = (composeId: string, token: string, currentTrends: ImmutableList) => ({ 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, diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index 1a90a259f..cb23f3dae 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -177,7 +177,6 @@ const toggleFavourite = (status: StatusEntity) => } }; - const favouriteRequest = (status: StatusEntity) => ({ type: FAVOURITE_REQUEST, status: status, diff --git a/app/soapbox/actions/moderation.tsx b/app/soapbox/actions/moderation.tsx index ea5861eca..bf0ccf332 100644 --- a/app/soapbox/actions/moderation.tsx +++ b/app/soapbox/actions/moderation.tsx @@ -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 = ( + + + + + + + {intl.formatMessage(messages.deactivateUserPrompt, { acct })} + + + ); + 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 = (<> - - {intl.formatMessage(messages.deleteUserPrompt, { acct })} - ); + const message = ( + + + + - const confirm = (<> - {favicon && -
- -
} - {intl.formatMessage(messages.deleteUserConfirm, { name })} - ); + + {intl.formatMessage(messages.deleteUserPrompt, { acct })} + +
+ ); + const confirm = intl.formatMessage(messages.deleteUserConfirm, { name }); const checkbox = local ? intl.formatMessage(messages.deleteLocalUserCheckbox) : false; dispatch(openModal('CONFIRM', { diff --git a/app/soapbox/actions/search.ts b/app/soapbox/actions/search.ts index e8718d479..a2f165ac0 100644 --- a/app/soapbox/actions/search.ts +++ b/app/soapbox/actions/search.ts @@ -8,9 +8,10 @@ import type { SearchFilter } from 'soapbox/reducers/search'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { APIEntity } from 'soapbox/types/entities'; -const SEARCH_CHANGE = 'SEARCH_CHANGE'; -const SEARCH_CLEAR = 'SEARCH_CLEAR'; -const SEARCH_SHOW = 'SEARCH_SHOW'; +const SEARCH_CHANGE = 'SEARCH_CHANGE'; +const SEARCH_CLEAR = 'SEARCH_CLEAR'; +const SEARCH_SHOW = 'SEARCH_SHOW'; +const SEARCH_RESULTS_CLEAR = 'SEARCH_RESULTS_CLEAR'; const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; @@ -28,7 +29,11 @@ const changeSearch = (value: string) => (dispatch: AppDispatch) => { // If backspaced all the way, clear the search if (value.length === 0) { - return dispatch(clearSearch()); + dispatch(clearSearchResults()); + return dispatch({ + type: SEARCH_CHANGE, + value, + }); } else { return dispatch({ type: SEARCH_CHANGE, @@ -41,6 +46,10 @@ const clearSearch = () => ({ type: SEARCH_CLEAR, }); +const clearSearchResults = () => ({ + type: SEARCH_RESULTS_CLEAR, +}); + const submitSearch = (filter?: SearchFilter) => (dispatch: AppDispatch, getState: () => RootState) => { const value = getState().search.value; @@ -167,6 +176,7 @@ export { SEARCH_CHANGE, SEARCH_CLEAR, SEARCH_SHOW, + SEARCH_RESULTS_CLEAR, SEARCH_FETCH_REQUEST, SEARCH_FETCH_SUCCESS, SEARCH_FETCH_FAIL, @@ -177,6 +187,7 @@ export { SEARCH_ACCOUNT_SET, changeSearch, clearSearch, + clearSearchResults, submitSearch, fetchSearchRequest, fetchSearchSuccess, diff --git a/app/soapbox/actions/security.ts b/app/soapbox/actions/security.ts index 430691a06..196e54dcb 100644 --- a/app/soapbox/actions/security.ts +++ b/app/soapbox/actions/security.ts @@ -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 diff --git a/app/soapbox/components/announcements/reactions-bar.tsx b/app/soapbox/components/announcements/reactions-bar.tsx index 5cec53974..130db2d99 100644 --- a/app/soapbox/components/announcements/reactions-bar.tsx +++ b/app/soapbox/components/announcements/reactions-bar.tsx @@ -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'; diff --git a/app/soapbox/components/autosuggest_textarea.tsx b/app/soapbox/components/autosuggest_textarea.tsx index 47f0eea39..c1c849b07 100644 --- a/app/soapbox/components/autosuggest_textarea.tsx +++ b/app/soapbox/components/autosuggest_textarea.tsx @@ -46,8 +46,8 @@ interface IAutosuggesteTextarea { onSuggestionsClearRequested: () => void, onSuggestionsFetchRequested: (token: string | number) => void, onChange: React.ChangeEventHandler, - onKeyUp: React.KeyboardEventHandler, - onKeyDown: React.KeyboardEventHandler, + onKeyUp?: React.KeyboardEventHandler, + onKeyDown?: React.KeyboardEventHandler, onPaste: (files: FileList) => void, autoFocus: boolean, onFocus: () => void, diff --git a/app/soapbox/components/badge.tsx b/app/soapbox/components/badge.tsx index 13646bcdb..01b792bd0 100644 --- a/app/soapbox/components/badge.tsx +++ b/app/soapbox/components/badge.tsx @@ -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 = ({ title, slug }) => ( - - {title} - -); +const Badge: React.FC = ({ title, slug }) => { + const fallback = !['patron', 'admin', 'moderator', 'opaque', 'badge:donor'].includes(slug); + + return ( + + {title} + + ); +}; export default Badge; diff --git a/app/soapbox/components/list.tsx b/app/soapbox/components/list.tsx index ae29a5cbd..b16a36784 100644 --- a/app/soapbox/components/list.tsx +++ b/app/soapbox/components/list.tsx @@ -34,7 +34,7 @@ const ListItem: React.FC = ({ label, hint, children, onClick }) => { id: domId, className: classNames({ 'w-auto': isSelect, - }), + }, child.props.className), }); } diff --git a/app/soapbox/components/modal_root.js b/app/soapbox/components/modal_root.js index 23d392b51..9190c37d4 100644 --- a/app/soapbox/components/modal_root.js +++ b/app/soapbox/components/modal_root.js @@ -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) => ({ diff --git a/app/soapbox/components/outline-box.tsx b/app/soapbox/components/outline-box.tsx new file mode 100644 index 000000000..41a13bf49 --- /dev/null +++ b/app/soapbox/components/outline-box.tsx @@ -0,0 +1,21 @@ +import classNames from 'clsx'; +import React from 'react'; + +interface IOutlineBox extends React.HTMLAttributes { + children: React.ReactNode, + className?: string, +} + +/** Wraps children in a container with an outline. */ +const OutlineBox: React.FC = ({ children, className, ...rest }) => { + return ( +
+ {children} +
+ ); +}; + +export default OutlineBox; \ No newline at end of file diff --git a/app/soapbox/components/polls/poll-footer.tsx b/app/soapbox/components/polls/poll-footer.tsx index dfa91e663..693b28db1 100644 --- a/app/soapbox/components/polls/poll-footer.tsx +++ b/app/soapbox/components/polls/poll-footer.tsx @@ -45,7 +45,6 @@ const PollFooter: React.FC = ({ poll, showResults, selected }): JSX votesCount = ; } - return ( {(!showResults && poll?.multiple) && ( diff --git a/app/soapbox/components/profile-hover-card.tsx b/app/soapbox/components/profile-hover-card.tsx index c96839c77..3775a107c 100644 --- a/app/soapbox/components/profile-hover-card.tsx +++ b/app/soapbox/components/profile-hover-card.tsx @@ -37,10 +37,6 @@ const getBadges = (account: Account): JSX.Element[] => { badges.push(); } - if (account.donor) { - badges.push(); - } - return badges; }; diff --git a/app/soapbox/components/quoted-status.tsx b/app/soapbox/components/quoted-status.tsx index a8809bedf..d7ecfaf05 100644 --- a/app/soapbox/components/quoted-status.tsx +++ b/app/soapbox/components/quoted-status.tsx @@ -9,6 +9,8 @@ import AccountContainer from 'soapbox/containers/account_container'; import { useSettings } from 'soapbox/hooks'; import { defaultMediaVisibility } from 'soapbox/utils/status'; +import OutlineBox from './outline-box'; + import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; const messages = defineMessages({ @@ -123,38 +125,41 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => } return ( - - + + - {renderReplyMentions()} + {renderReplyMentions()} - + - - + + + ); }; diff --git a/app/soapbox/components/sidebar_menu.tsx b/app/soapbox/components/sidebar_menu.tsx index 4f349fd1c..817e1f0d2 100644 --- a/app/soapbox/components/sidebar_menu.tsx +++ b/app/soapbox/components/sidebar_menu.tsx @@ -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 = 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) && ( + + )} + {features.bookmarks && ( = ({ const handleReplyClick: React.MouseEventHandler = (e) => { if (me) { - dispatch((_, getState) => { - const state = getState(); - if (state.compose.text.trim().length !== 0) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(replyCompose(status)), - })); - } else { - dispatch(replyCompose(status)); - } - }); + dispatch(replyCompose(status)); } else { onOpenUnauthorizedModal('REPLY'); } @@ -186,18 +175,7 @@ const StatusActionBar: React.FC = ({ e.stopPropagation(); if (me) { - dispatch((_, getState) => { - const state = getState(); - if (state.compose.text.trim().length !== 0) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(quoteCompose(status)), - })); - } else { - dispatch(quoteCompose(status)); - } - }); + dispatch(quoteCompose(status)); } else { onOpenUnauthorizedModal('REBLOG'); } @@ -321,14 +299,10 @@ const StatusActionBar: React.FC = ({ } }; - const handleDeactivateUser: React.EventHandler = (e) => { + const onModerate: React.MouseEventHandler = (e) => { e.stopPropagation(); - dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']) as string)); - }; - - const handleDeleteUser: React.EventHandler = (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 = (e) => { @@ -474,13 +448,13 @@ const StatusActionBar: React.FC = ({ if (isStaff) { menu.push(null); + menu.push({ + text: intl.formatMessage(messages.adminAccount, { name: username }), + action: onModerate, + icon: require('@tabler/icons/gavel.svg'), + }); + if (isAdmin) { - menu.push({ - text: intl.formatMessage(messages.admin_account, { name: username }), - href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`, - icon: require('@tabler/icons/gavel.svg'), - action: (event) => event.stopPropagation(), - }); menu.push({ text: intl.formatMessage(messages.admin_status), href: `/pleroma/admin/#/statuses/${status.id}/`, @@ -496,17 +470,6 @@ const StatusActionBar: React.FC = ({ }); 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, diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index dd2a15142..609124c20 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -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 = (props) => { const handleHotkeyReply = (e?: KeyboardEvent): void => { e?.preventDefault(); - dispatch(replyComposeWithConfirmation(actualStatus, intl)); + dispatch(replyCompose(actualStatus)); }; const handleHotkeyFavourite = (): void => { diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 295538da2..3bee7a03a 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -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 = ({ }) => { 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).toJS(); + const node = useRef(null); + const seed = useRef(uuidv4()); const getFeaturedStatusCount = () => { return featuredStatusIds?.size || 0; @@ -132,9 +139,10 @@ const StatusList: React.FC = ({ ); }; - const renderAd = (ad: AdEntity) => { + const renderAd = (ad: AdEntity, index: number) => { return ( = ({ 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 = ({ acc.push(renderStatus(statusId)); } - if (showAds && ad && showAd) { - acc.push(renderAd(ad)); - } - return acc; }, [] as React.ReactNode[]); } else { diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index 5eff0a78f..fd9cb055e 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -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'; diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 2bd77dbfa..c77ae8169 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -36,6 +36,7 @@ interface IInput extends Pick, '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, } diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index e75dfaae3..ab347ad6d 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -39,7 +39,7 @@ interface IStack extends React.HTMLAttributes { } /** Vertical stack of child elements. */ -const Stack: React.FC = React.forwardRef((props, ref: React.LegacyRef | undefined) => { +const Stack = React.forwardRef((props, ref: React.LegacyRef | undefined) => { const { space, alignItems, justifyContent, className, grow, ...filteredProps } = props; return ( diff --git a/app/soapbox/components/ui/tag-input/tag-input.tsx b/app/soapbox/components/ui/tag-input/tag-input.tsx new file mode 100644 index 000000000..e4f8548ab --- /dev/null +++ b/app/soapbox/components/ui/tag-input/tag-input.tsx @@ -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 = ({ 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 ( +
+ + {tags.map((tag, i) => ( +
+ +
+ ))} + + setInput(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+
+ ); +}; + +export default TagInput; \ No newline at end of file diff --git a/app/soapbox/components/ui/tag-input/tag.tsx b/app/soapbox/components/ui/tag-input/tag.tsx new file mode 100644 index 000000000..d47b1d73b --- /dev/null +++ b/app/soapbox/components/ui/tag-input/tag.tsx @@ -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 = ({ tag, onDelete }) => { + return ( +
+ {tag} + + onDelete(tag)} + transparent + /> +
+ ); +}; + +export default Tag; \ No newline at end of file diff --git a/app/soapbox/containers/status_container.tsx b/app/soapbox/containers/status_container.tsx index 3f7c1a34f..89c387ffd 100644 --- a/app/soapbox/containers/status_container.tsx +++ b/app/soapbox/containers/status_container.tsx @@ -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 { 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 = (props) => { const { id, ...rest } = props; + + const getStatus = useCallback(makeGetStatus(), []); const status = useAppSelector(state => getStatus(state, { id })); if (status) { diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index e1a573dde..ea57ebb84 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -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 = ({ 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 = ({ account }) => { to: '/settings', icon: require('@tabler/icons/settings.svg'), }); + if (features.searchFromAccount) { + menu.push({ + text: intl.formatMessage(messages.searchSelf, { name: account.username }), + action: onSearch, + icon: require('@tabler/icons/search.svg'), + }); + } menu.push(null); menu.push({ text: intl.formatMessage(messages.mutes), @@ -524,107 +431,11 @@ const Header: React.FC = ({ account }) => { if (ownAccount?.staff) { menu.push(null); - if (ownAccount?.admin) { - menu.push({ - text: intl.formatMessage(messages.admin_account, { name: account.username }), - to: `/pleroma/admin/#/users/${account.id}/`, - newTab: true, - icon: require('@tabler/icons/gavel.svg'), - }); - } - - if (account.id !== ownAccount?.id && isLocal(account) && ownAccount.admin) { - if (account.admin) { - menu.push({ - text: intl.formatMessage(messages.demoteToModerator, { name: account.username }), - action: onPromoteToModerator, - icon: require('@tabler/icons/arrow-up-circle.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.demoteToUser, { name: account.username }), - action: onDemoteToUser, - icon: require('@tabler/icons/arrow-down-circle.svg'), - }); - } else if (account.moderator) { - menu.push({ - text: intl.formatMessage(messages.promoteToAdmin, { name: account.username }), - action: onPromoteToAdmin, - icon: require('@tabler/icons/arrow-up-circle.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.demoteToUser, { name: account.username }), - action: onDemoteToUser, - icon: require('@tabler/icons/arrow-down-circle.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.promoteToAdmin, { name: account.username }), - action: onPromoteToAdmin, - icon: require('@tabler/icons/arrow-up-circle.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.promoteToModerator, { name: account.username }), - action: onPromoteToModerator, - icon: require('@tabler/icons/arrow-up-circle.svg'), - }); - } - } - - if (account.verified) { - menu.push({ - text: intl.formatMessage(messages.unverifyUser, { name: account.username }), - action: onUnverifyUser, - icon: require('@tabler/icons/check.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.verifyUser, { name: account.username }), - action: onVerifyUser, - icon: require('@tabler/icons/check.svg'), - }); - } - - if (account.donor) { - menu.push({ - text: intl.formatMessage(messages.removeDonor, { name: account.username }), - action: onRemoveDonor, - icon: require('@tabler/icons/coin.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.setDonor, { name: account.username }), - action: onSetDonor, - icon: require('@tabler/icons/coin.svg'), - }); - } - - if (features.suggestionsV2 && ownAccount.admin) { - if (account.getIn(['pleroma', 'is_suggested'])) { - menu.push({ - text: intl.formatMessage(messages.unsuggestUser, { name: account.username }), - action: onUnsuggestUser, - icon: require('@tabler/icons/user-x.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.suggestUser, { name: account.username }), - action: onSuggestUser, - icon: require('@tabler/icons/user-check.svg'), - }); - } - } - - if (account.id !== ownAccount?.id) { - menu.push({ - text: intl.formatMessage(messages.deactivateUser, { name: account.username }), - action: onDeactivateUser, - icon: require('@tabler/icons/user-off.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.deleteUser, { name: account.username }), - icon: require('@tabler/icons/user-minus.svg'), - }); - } + menu.push({ + text: intl.formatMessage(messages.adminAccount, { name: account.username }), + action: onModerate, + icon: require('@tabler/icons/gavel.svg'), + }); } return menu; diff --git a/app/soapbox/features/admin/components/report.tsx b/app/soapbox/features/admin/components/report.tsx index fb1c5bf5d..75c3ecb1d 100644 --- a/app/soapbox/features/admin/components/report.tsx +++ b/app/soapbox/features/admin/components/report.tsx @@ -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 = ({ report }) => { +const Report: React.FC = ({ 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; diff --git a/app/soapbox/features/admin/components/unapproved_account.tsx b/app/soapbox/features/admin/components/unapproved_account.tsx index b5a08c956..a01421643 100644 --- a/app/soapbox/features/admin/components/unapproved_account.tsx +++ b/app/soapbox/features/admin/components/unapproved_account.tsx @@ -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 = ({ 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)); diff --git a/app/soapbox/features/admin/tabs/reports.tsx b/app/soapbox/features/admin/tabs/reports.tsx index 8a2eca8c7..5c2854cc4 100644 --- a/app/soapbox/features/admin/tabs/reports.tsx +++ b/app/soapbox/features/admin/tabs/reports.tsx @@ -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 && )} + {reports.map(report => report && )} ); }; diff --git a/app/soapbox/features/aliases/components/account.tsx b/app/soapbox/features/aliases/components/account.tsx index 3d24c0543..be9178b8d 100644 --- a/app/soapbox/features/aliases/components/account.tsx +++ b/app/soapbox/features/aliases/components/account.tsx @@ -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 @@ -25,6 +23,8 @@ interface IAccount { const Account: React.FC = ({ accountId, aliases }) => { const intl = useIntl(); const dispatch = useAppDispatch(); + + const getAccount = useCallback(makeGetAccount(), []); const account = useAppSelector((state) => getAccount(state, accountId)); const added = useAppSelector((state) => { diff --git a/app/soapbox/features/birthdays/account.tsx b/app/soapbox/features/birthdays/account.tsx index 738ad5ec9..745ca5268 100644 --- a/app/soapbox/features/birthdays/account.tsx +++ b/app/soapbox/features/birthdays/account.tsx @@ -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 = ({ accountId }) => { const intl = useIntl(); + const getAccount = useCallback(makeGetAccount(), []); + const account = useAppSelector((state) => getAccount(state, accountId)); // useEffect(() => { @@ -30,7 +30,7 @@ const Account: React.FC = ({ 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 = ({ accountId }) => { return (
- +
diff --git a/app/soapbox/features/compose/components/autosuggest_account.tsx b/app/soapbox/features/compose/components/autosuggest_account.tsx index 6b65f8341..511d65fe6 100644 --- a/app/soapbox/features/compose/components/autosuggest_account.tsx +++ b/app/soapbox/features/compose/components/autosuggest_account.tsx @@ -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 = ({ id }) => { - const getAccount = makeGetAccount(); + const getAccount = useCallback(makeGetAccount(), []); const account = useAppSelector((state) => getAccount(state, id)); if (!account) return null; diff --git a/app/soapbox/features/compose/components/compose-form.tsx b/app/soapbox/features/compose/components/compose-form.tsx new file mode 100644 index 000000000..35470c4cf --- /dev/null +++ b/app/soapbox/features/compose/components/compose-form.tsx @@ -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: ID extends 'default' ? never : ID, + shouldCondense?: boolean, + autoFocus?: boolean, + clickableAreaRef?: React.RefObject, +} + +const ComposeForm = ({ id, shouldCondense, autoFocus, clickableAreaRef }: IComposeForm) => { + 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(null); + const autosuggestTextareaRef = useRef(null); + + const handleChange: React.ChangeEventHandler = (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 = (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(() => ( +
+ {features.media && } + + {features.polls && } + {features.privacyScopes && } + {features.scheduledStatuses && } + {features.spoilers && } + {features.richText && } +
+ ), [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 = ( + <> + + {intl.formatMessage(messages.message)} + + ); + } else if (privacy === 'private') { + publishText = ( + <> + + {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 ( + + {scheduledStatusCount > 0 && ( + + + + ) }} + />) + } + /> + )} + + + + {!shouldCondense && } + + {!shouldCondense && } + +
+ +
+ + + { + !condensed && +
+ + + +
+ } +
+ + + +
+ {renderButtons()} + +
+ {maxTootChars && ( +
+ + +
+ )} + +
+
+
+ ); +}; + +export default ComposeForm; diff --git a/app/soapbox/features/compose/components/compose_form.js b/app/soapbox/features/compose/components/compose_form.js deleted file mode 100644 index da66083a6..000000000 --- a/app/soapbox/features/compose/components/compose_form.js +++ /dev/null @@ -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 = ( - <> - - {intl.formatMessage(messages.message)} - - ); - } else if (this.props.privacy === 'private') { - publishText = ( - <> - - {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 ( - - {scheduledStatusCount > 0 && ( - - - - ) }} - />) - } - /> - )} - - - - {!shouldCondense && } - - {!shouldCondense && } - -
- -
- - - { - !condensed && -
- - - -
- } -
- - - -
-
- {features.media && } - - {features.polls && } - {features.privacyScopes && } - {features.scheduledStatuses && } - {features.spoilers && } - {features.richText && } -
- -
- {maxTootChars && ( -
- - -
- )} - -
-
-
- ); - } - -} diff --git a/app/soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown.tsx b/app/soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown.tsx new file mode 100644 index 000000000..697f38c8c --- /dev/null +++ b/app/soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown.tsx @@ -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>, +], 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>); + +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 = ({ 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 = (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 = (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 ( +
+
+ {button || } +
+ + + + +
+ ); +}; + +export { EmojiPicker, Emoji }; + +export default EmojiPickerDropdown; diff --git a/app/soapbox/features/compose/components/emoji-picker/emoji-picker-menu.tsx b/app/soapbox/features/compose/components/emoji-picker/emoji-picker-menu.tsx new file mode 100644 index 000000000..7cb12e8f5 --- /dev/null +++ b/app/soapbox/features/compose/components/emoji-picker/emoji-picker-menu.tsx @@ -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>, + loading?: boolean, + onClose: () => void, + onPick: (emoji: Emoji) => void, + onSkinTone: (skinTone: number) => void, + skinTone?: number, + frequentlyUsedEmojis?: Array, + style?: React.CSSProperties, +} + +const EmojiPickerMenu: React.FC = ({ + customEmojis, + loading = true, + onClose, + onPick, + onSkinTone, + skinTone, + frequentlyUsedEmojis = [], + style = {}, +}) => { + const intl = useIntl(); + + const node = useRef(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
; + } + + const title = intl.formatMessage(messages.emoji); + + return ( +
+ + + +
+ ); +}; + +export default EmojiPickerMenu; diff --git a/app/soapbox/features/compose/components/emoji-picker/modifier-picker-menu.tsx b/app/soapbox/features/compose/components/emoji-picker/modifier-picker-menu.tsx new file mode 100644 index 000000000..b62053ca5 --- /dev/null +++ b/app/soapbox/features/compose/components/emoji-picker/modifier-picker-menu.tsx @@ -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 = ({ active, onSelect, onClose }) => { + const node = useRef(null); + + const handleClick: React.MouseEventHandler = 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 ( +
+ + + + + + +
+ ); +}; + +export default ModifierPickerMenu; diff --git a/app/soapbox/features/compose/components/emoji-picker/modifier-picker.tsx b/app/soapbox/features/compose/components/emoji-picker/modifier-picker.tsx new file mode 100644 index 000000000..a84b71122 --- /dev/null +++ b/app/soapbox/features/compose/components/emoji-picker/modifier-picker.tsx @@ -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 = ({ active, modifier, onOpen, onClose, onChange }) => { + const handleClick = () => { + if (active) { + onClose(); + } else { + onOpen(); + } + }; + + const handleSelect = (modifier: number) => { + onChange(modifier); + onClose(); + }; + + return ( +
+ + +
+ ); +}; + +export default ModifierPicker; diff --git a/app/soapbox/features/compose/components/emoji_picker_dropdown.js b/app/soapbox/features/compose/components/emoji_picker_dropdown.js deleted file mode 100644 index 9deddd623..000000000 --- a/app/soapbox/features/compose/components/emoji_picker_dropdown.js +++ /dev/null @@ -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 ( -
- - - - - - -
- ); - } - -} - -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 ( -
- - -
- ); - } - -} - -@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
; - } - - const title = intl.formatMessage(messages.emoji); - const { modifierOpen } = this.state; - - return ( -
- - - -
- ); - } - -} - -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 ( -
-
- {button || } -
- - - - -
- ); - } - -} diff --git a/app/soapbox/features/compose/components/markdown_button.tsx b/app/soapbox/features/compose/components/markdown_button.tsx index 7d0d56eb6..0f44d8786 100644 --- a/app/soapbox/features/compose/components/markdown_button.tsx +++ b/app/soapbox/features/compose/components/markdown_button.tsx @@ -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 = ({ active, onClick }) => { +const MarkdownButton: React.FC = ({ 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 ( void, } -const PollButton: React.FC = ({ active, unavailable, disabled, onClick }) => { +const PollButton: React.FC = ({ 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; diff --git a/app/soapbox/features/compose/components/polls/poll-form.tsx b/app/soapbox/features/compose/components/polls/poll-form.tsx index 9cf727081..4daf54048 100644 --- a/app/soapbox/features/compose/components/polls/poll-form.tsx +++ b/app/soapbox/features/compose/components/polls/poll-form.tsx @@ -4,7 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { addPollOption, changePollOption, changePollSettings, clearComposeSuggestions, fetchComposeSuggestions, removePoll, removePollOption, selectComposeSuggestion } from 'soapbox/actions/compose'; import AutosuggestInput from 'soapbox/components/autosuggest_input'; import { Button, Divider, HStack, Stack, Text, Toggle } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useCompose } from 'soapbox/hooks'; import DurationSelector from './duration-selector'; @@ -26,6 +26,7 @@ const messages = defineMessages({ }); interface IOption { + composeId: string index: number maxChars: number numOptions: number @@ -35,21 +36,20 @@ interface IOption { title: string } -const Option = (props: IOption) => { - const { - index, - maxChars, - numOptions, - onChange, - onRemove, - onRemovePoll, - title, - } = props; - +const Option: React.FC = ({ + composeId, + index, + maxChars, + numOptions, + onChange, + onRemove, + onRemovePoll, + title, +}) => { const dispatch = useAppDispatch(); const intl = useIntl(); - const suggestions = useAppSelector((state) => state.compose.suggestions); + const suggestions = useCompose(composeId).suggestions; const handleOptionTitleChange = (event: React.ChangeEvent) => 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 = ({ 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 = () => { {options.map((title: string, i: number) => (