Merge branch 'lexical' into 'main'

Lexical text editor

See merge request soapbox-pub/soapbox!2177
This commit is contained in:
Alex Gleason 2023-09-23 21:00:32 +00:00
commit 6c318a02ef
68 changed files with 2712 additions and 365 deletions

View File

@ -20,8 +20,8 @@ module.exports = {
},
plugins: [
'react',
'jsdoc',
'react',
'jsx-a11y',
'import',
'promise',

View File

@ -50,6 +50,17 @@
"@fontsource/inter": "^5.0.0",
"@fontsource/roboto-mono": "^5.0.0",
"@gamestdio/websocket": "^0.3.2",
"@lexical/clipboard": "^0.11.3",
"@lexical/code": "^0.11.3",
"@lexical/hashtag": "^0.11.3",
"@lexical/html": "^0.11.3",
"@lexical/link": "^0.11.3",
"@lexical/list": "^0.11.3",
"@lexical/react": "^0.11.3",
"@lexical/rich-text": "^0.11.3",
"@lexical/selection": "^0.11.3",
"@lexical/table": "^0.11.3",
"@lexical/utils": "^0.11.3",
"@popperjs/core": "^2.11.5",
"@reach/combobox": "^0.18.0",
"@reach/menu-button": "^0.18.0",
@ -112,6 +123,7 @@
"intl-messageformat-parser": "^6.0.0",
"intl-pluralrules": "^2.0.0",
"leaflet": "^1.8.0",
"lexical": "^0.11.3",
"line-awesome": "^1.3.0",
"localforage": "^1.10.0",
"lodash": "^4.7.11",

View File

@ -66,7 +66,7 @@ describe('fetchMe()', () => {
});
it('dispatches the correct actions', async() => {
const expectedActions = [
const expectedActions = [
{ type: 'ME_FETCH_REQUEST' },
{ type: 'AUTH_ACCOUNT_REMEMBER_REQUEST', accountUrl },
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
@ -102,7 +102,7 @@ describe('patchMe()', () => {
});
it('dispatches the correct actions', async() => {
const expectedActions = [
const expectedActions = [
{ type: 'ME_PATCH_REQUEST' },
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
{

View File

@ -21,7 +21,7 @@ describe('markReadNotifications()', () => {
const store = mockStore(state);
const expectedActions = [{
const expectedActions = [{
type: 'MARKER_SAVE_REQUEST',
marker: {
notifications: {

View File

@ -6,21 +6,21 @@ import { defineMessages, IntlShape } from 'react-intl';
import api from 'soapbox/api';
import { isNativeEmoji } from 'soapbox/features/emoji';
import emojiSearch from 'soapbox/features/emoji/search';
import { normalizeTag } from 'soapbox/normalizers';
import { selectAccount, selectOwnAccount } from 'soapbox/selectors';
import { tagHistory } from 'soapbox/settings';
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures, parseVersion } from 'soapbox/utils/features';
import { formatBytes, getVideoDuration } from 'soapbox/utils/media';
import resizeImage from 'soapbox/utils/resize-image';
import { useEmoji } from './emojis';
import { importFetchedAccounts } from './importer';
import { uploadMedia, fetchMedia, updateMedia } from './media';
import { uploadFile, updateMedia } from './media';
import { openModal, closeModal } from './modals';
import { getSettings } from './settings';
import { createStatus } from './statuses';
import type { EditorState } from 'lexical';
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import type { Emoji } from 'soapbox/features/emoji';
import type { Account, Group } from 'soapbox/schemas';
@ -30,7 +30,7 @@ import type { History } from 'soapbox/types/history';
const { CancelToken, isCancel } = axios;
let cancelFetchComposeSuggestionsAccounts: Canceler;
let cancelFetchComposeSuggestions: Canceler;
const COMPOSE_CHANGE = 'COMPOSE_CHANGE' as const;
const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const;
@ -87,10 +87,9 @@ const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS' as const;
const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS' as const;
const COMPOSE_EDITOR_STATE_SET = 'COMPOSE_EDITOR_STATE_SET' as const;
const messages = defineMessages({
exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' },
exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' },
exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit, plural, one {# second} other {# seconds}})' },
scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' },
success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' },
editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' },
@ -304,6 +303,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const { richText } = getFeatures(state.instance);
const compose = state.compose.get(composeId)!;
@ -312,6 +312,8 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
const statusId = compose.id;
let to = compose.to;
const contentType = richText ? 'text/markdown' : 'text/plain';
if (!validateSchedule(state, composeId)) {
toast.error(messages.scheduleError);
return;
@ -350,7 +352,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
sensitive: compose.sensitive,
spoiler_text: compose.spoiler_text,
visibility: compose.privacy,
content_type: compose.content_type,
content_type: contentType,
poll: compose.poll,
scheduled_at: compose.schedule,
to,
@ -392,9 +394,6 @@ 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;
const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined;
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.get(composeId)?.media_attachments;
const progress = new Array(files.length).fill(0);
@ -412,67 +411,49 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
Array.from(files).forEach(async(f, i) => {
if (mediaCount + i > attachmentLimit - 1) return;
const isImage = f.type.match(/image.*/);
const isVideo = f.type.match(/video.*/);
const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(f) : 0;
if (isImage && maxImageSize && (f.size > maxImageSize)) {
const limit = formatBytes(maxImageSize);
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
toast.error(message);
dispatch(uploadComposeFail(composeId, true));
return;
} else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) {
const limit = formatBytes(maxVideoSize);
const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit });
toast.error(message);
dispatch(uploadComposeFail(composeId, true));
return;
} else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) {
const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration });
toast.error(message);
dispatch(uploadComposeFail(composeId, true));
return;
}
// FIXME: Don't define const in loop
/* eslint-disable no-loop-func */
resizeImage(f).then(file => {
const data = new FormData();
data.append('file', file);
// Account for disparity in size of original image and resized data
total += file.size - f.size;
const onUploadProgress = ({ loaded }: any) => {
dispatch(uploadFile(
f,
intl,
(data) => dispatch(uploadComposeSuccess(composeId, data, f)),
(error) => dispatch(uploadComposeFail(composeId, error)),
({ loaded }: any) => {
progress[i] = loaded;
dispatch(uploadComposeProgress(composeId, progress.reduce((a, v) => a + v, 0), total));
};
},
(value) => total += value,
));
return dispatch(uploadMedia(data, onUploadProgress))
.then(({ status, data }) => {
// If server-side processing of the media attachment has not completed yet,
// poll the server until it is, before showing the media attachment as uploaded
if (status === 200) {
dispatch(uploadComposeSuccess(composeId, data, f));
} else if (status === 202) {
const poll = () => {
dispatch(fetchMedia(data.id)).then(({ status, data }) => {
if (status === 200) {
dispatch(uploadComposeSuccess(composeId, data, f));
} else if (status === 206) {
setTimeout(() => poll(), 1000);
}
}).catch(error => dispatch(uploadComposeFail(composeId, error)));
};
poll();
}
});
}).catch(error => dispatch(uploadComposeFail(composeId, error)));
/* eslint-enable no-loop-func */
});
};
const uploadComposeRequest = (composeId: string) => ({
type: COMPOSE_UPLOAD_REQUEST,
id: composeId,
skipLoading: true,
});
const uploadComposeProgress = (composeId: string, loaded: number, total: number) => ({
type: COMPOSE_UPLOAD_PROGRESS,
id: composeId,
loaded: loaded,
total: total,
});
const uploadComposeSuccess = (composeId: string, media: APIEntity, file: File) => ({
type: COMPOSE_UPLOAD_SUCCESS,
id: composeId,
media: media,
file,
skipLoading: true,
});
const uploadComposeFail = (composeId: string, error: AxiosError | true) => ({
type: COMPOSE_UPLOAD_FAIL,
id: composeId,
error: error,
skipLoading: true,
});
const changeUploadCompose = (composeId: string, id: string, params: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
@ -507,34 +488,6 @@ const changeUploadComposeFail = (composeId: string, id: string, error: AxiosErro
skipLoading: true,
});
const uploadComposeRequest = (composeId: string) => ({
type: COMPOSE_UPLOAD_REQUEST,
id: composeId,
skipLoading: true,
});
const uploadComposeProgress = (composeId: string, loaded: number, total: number) => ({
type: COMPOSE_UPLOAD_PROGRESS,
id: composeId,
loaded: loaded,
total: total,
});
const uploadComposeSuccess = (composeId: string, media: APIEntity, file: File) => ({
type: COMPOSE_UPLOAD_SUCCESS,
id: composeId,
media: media,
file,
skipLoading: true,
});
const uploadComposeFail = (composeId: string, error: AxiosError | true) => ({
type: COMPOSE_UPLOAD_FAIL,
id: composeId,
error: error,
skipLoading: true,
});
const undoUploadCompose = (composeId: string, media_id: string) => ({
type: COMPOSE_UPLOAD_UNDO,
id: composeId,
@ -554,8 +507,8 @@ const setGroupTimelineVisible = (composeId: string, groupTimelineVisible: boolea
});
const clearComposeSuggestions = (composeId: string) => {
if (cancelFetchComposeSuggestionsAccounts) {
cancelFetchComposeSuggestionsAccounts();
if (cancelFetchComposeSuggestions) {
cancelFetchComposeSuggestions();
}
return {
type: COMPOSE_SUGGESTIONS_CLEAR,
@ -564,12 +517,12 @@ const clearComposeSuggestions = (composeId: string) => {
};
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => {
if (cancelFetchComposeSuggestionsAccounts) {
cancelFetchComposeSuggestionsAccounts(composeId);
if (cancelFetchComposeSuggestions) {
cancelFetchComposeSuggestions(composeId);
}
api(getState).get('/api/v1/accounts/search', {
cancelToken: new CancelToken(cancel => {
cancelFetchComposeSuggestionsAccounts = cancel;
cancelFetchComposeSuggestions = cancel;
}),
params: {
q: token.slice(1),
@ -594,10 +547,37 @@ const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => Ro
};
const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
const state = getState();
const currentTrends = state.trends.items;
if (cancelFetchComposeSuggestions) {
cancelFetchComposeSuggestions(composeId);
}
dispatch(updateSuggestionTags(composeId, token, currentTrends));
const state = getState();
const instance = state.instance;
const { trends } = getFeatures(instance);
if (trends) {
const currentTrends = state.trends.items;
return dispatch(updateSuggestionTags(composeId, token, currentTrends));
}
api(getState).get('/api/v2/search', {
cancelToken: new CancelToken(cancel => {
cancelFetchComposeSuggestions = cancel;
}),
params: {
q: token.slice(1),
limit: 4,
type: 'hashtags',
},
}).then(response => {
dispatch(updateSuggestionTags(composeId, token, response.data?.hashtags.map(normalizeTag)));
}).catch(error => {
if (!isCancel(error)) {
toast.showAlertForError(error);
}
});
};
const fetchComposeSuggestions = (composeId: string, token: string) =>
@ -675,11 +655,11 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str
dispatch(action);
};
const updateSuggestionTags = (composeId: string, token: string, currentTrends: ImmutableList<Tag>) => ({
const updateSuggestionTags = (composeId: string, token: string, tags: ImmutableList<Tag>) => ({
type: COMPOSE_SUGGESTION_TAGS_UPDATE,
id: composeId,
token,
currentTrends,
tags,
});
const updateTagHistory = (composeId: string, tags: string[]) => ({
@ -861,6 +841,12 @@ const eventDiscussionCompose = (composeId: string, status: Status) =>
});
};
const setEditorState = (composeId: string, editorState: EditorState | string | null) => ({
type: COMPOSE_EDITOR_STATE_SET,
id: composeId,
editorState: editorState,
});
type ComposeAction =
ComposeSetStatusAction
| ReturnType<typeof changeCompose>
@ -906,6 +892,7 @@ type ComposeAction =
| ComposeAddToMentionsAction
| ComposeRemoveFromMentionsAction
| ComposeEventReplyAction
| ReturnType<typeof setEditorState>
export {
COMPOSE_CHANGE,
@ -952,6 +939,7 @@ export {
COMPOSE_ADD_TO_MENTIONS,
COMPOSE_REMOVE_FROM_MENTIONS,
COMPOSE_SET_STATUS,
COMPOSE_EDITOR_STATE_SET,
COMPOSE_SET_GROUP_TIMELINE_VISIBLE,
setComposeToStatus,
changeCompose,
@ -968,6 +956,7 @@ export {
submitComposeRequest,
submitComposeSuccess,
submitComposeFail,
uploadFile,
uploadCompose,
changeUploadCompose,
changeUploadComposeRequest,
@ -1006,5 +995,6 @@ export {
addToMentions,
removeFromMentions,
eventDiscussionCompose,
setEditorState,
type ComposeAction,
};

View File

@ -162,7 +162,7 @@ const expandDomainBlocksSuccess = (domains: string[], next: string | null) => ({
next,
});
const expandDomainBlocksFail = (error: AxiosError) => ({
const expandDomainBlocksFail = (error: AxiosError) => ({
type: DOMAIN_BLOCKS_EXPAND_FAIL,
error,
});

View File

@ -2,11 +2,9 @@ import { defineMessages, IntlShape } from 'react-intl';
import api, { getLinks } from 'soapbox/api';
import toast from 'soapbox/toast';
import { formatBytes } from 'soapbox/utils/media';
import resizeImage from 'soapbox/utils/resize-image';
import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer';
import { fetchMedia, uploadMedia } from './media';
import { uploadFile } from './media';
import { closeModal, openModal } from './modals';
import {
STATUS_FETCH_SOURCE_FAIL,
@ -15,73 +13,74 @@ import {
} from './statuses';
import type { AxiosError } from 'axios';
import type { ReducerStatus } from 'soapbox/reducers/statuses';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities';
const LOCATION_SEARCH_REQUEST = 'LOCATION_SEARCH_REQUEST';
const LOCATION_SEARCH_SUCCESS = 'LOCATION_SEARCH_SUCCESS';
const LOCATION_SEARCH_FAIL = 'LOCATION_SEARCH_FAIL';
const LOCATION_SEARCH_REQUEST = 'LOCATION_SEARCH_REQUEST' as const;
const LOCATION_SEARCH_SUCCESS = 'LOCATION_SEARCH_SUCCESS' as const;
const LOCATION_SEARCH_FAIL = 'LOCATION_SEARCH_FAIL' as const;
const EDIT_EVENT_NAME_CHANGE = 'EDIT_EVENT_NAME_CHANGE';
const EDIT_EVENT_DESCRIPTION_CHANGE = 'EDIT_EVENT_DESCRIPTION_CHANGE';
const EDIT_EVENT_START_TIME_CHANGE = 'EDIT_EVENT_START_TIME_CHANGE';
const EDIT_EVENT_HAS_END_TIME_CHANGE = 'EDIT_EVENT_HAS_END_TIME_CHANGE';
const EDIT_EVENT_END_TIME_CHANGE = 'EDIT_EVENT_END_TIME_CHANGE';
const EDIT_EVENT_APPROVAL_REQUIRED_CHANGE = 'EDIT_EVENT_APPROVAL_REQUIRED_CHANGE';
const EDIT_EVENT_LOCATION_CHANGE = 'EDIT_EVENT_LOCATION_CHANGE';
const EDIT_EVENT_NAME_CHANGE = 'EDIT_EVENT_NAME_CHANGE' as const;
const EDIT_EVENT_DESCRIPTION_CHANGE = 'EDIT_EVENT_DESCRIPTION_CHANGE' as const;
const EDIT_EVENT_START_TIME_CHANGE = 'EDIT_EVENT_START_TIME_CHANGE' as const;
const EDIT_EVENT_HAS_END_TIME_CHANGE = 'EDIT_EVENT_HAS_END_TIME_CHANGE' as const;
const EDIT_EVENT_END_TIME_CHANGE = 'EDIT_EVENT_END_TIME_CHANGE' as const;
const EDIT_EVENT_APPROVAL_REQUIRED_CHANGE = 'EDIT_EVENT_APPROVAL_REQUIRED_CHANGE' as const;
const EDIT_EVENT_LOCATION_CHANGE = 'EDIT_EVENT_LOCATION_CHANGE' as const;
const EVENT_BANNER_UPLOAD_REQUEST = 'EVENT_BANNER_UPLOAD_REQUEST';
const EVENT_BANNER_UPLOAD_PROGRESS = 'EVENT_BANNER_UPLOAD_PROGRESS';
const EVENT_BANNER_UPLOAD_SUCCESS = 'EVENT_BANNER_UPLOAD_SUCCESS';
const EVENT_BANNER_UPLOAD_FAIL = 'EVENT_BANNER_UPLOAD_FAIL';
const EVENT_BANNER_UPLOAD_UNDO = 'EVENT_BANNER_UPLOAD_UNDO';
const EVENT_BANNER_UPLOAD_REQUEST = 'EVENT_BANNER_UPLOAD_REQUEST' as const;
const EVENT_BANNER_UPLOAD_PROGRESS = 'EVENT_BANNER_UPLOAD_PROGRESS' as const;
const EVENT_BANNER_UPLOAD_SUCCESS = 'EVENT_BANNER_UPLOAD_SUCCESS' as const;
const EVENT_BANNER_UPLOAD_FAIL = 'EVENT_BANNER_UPLOAD_FAIL' as const;
const EVENT_BANNER_UPLOAD_UNDO = 'EVENT_BANNER_UPLOAD_UNDO' as const;
const EVENT_SUBMIT_REQUEST = 'EVENT_SUBMIT_REQUEST';
const EVENT_SUBMIT_SUCCESS = 'EVENT_SUBMIT_SUCCESS';
const EVENT_SUBMIT_FAIL = 'EVENT_SUBMIT_FAIL';
const EVENT_SUBMIT_REQUEST = 'EVENT_SUBMIT_REQUEST' as const;
const EVENT_SUBMIT_SUCCESS = 'EVENT_SUBMIT_SUCCESS' as const;
const EVENT_SUBMIT_FAIL = 'EVENT_SUBMIT_FAIL' as const;
const EVENT_JOIN_REQUEST = 'EVENT_JOIN_REQUEST';
const EVENT_JOIN_SUCCESS = 'EVENT_JOIN_SUCCESS';
const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL';
const EVENT_JOIN_REQUEST = 'EVENT_JOIN_REQUEST' as const;
const EVENT_JOIN_SUCCESS = 'EVENT_JOIN_SUCCESS' as const;
const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL' as const;
const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST';
const EVENT_LEAVE_SUCCESS = 'EVENT_LEAVE_SUCCESS';
const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL';
const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST' as const;
const EVENT_LEAVE_SUCCESS = 'EVENT_LEAVE_SUCCESS' as const;
const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL' as const;
const EVENT_PARTICIPATIONS_FETCH_REQUEST = 'EVENT_PARTICIPATIONS_FETCH_REQUEST';
const EVENT_PARTICIPATIONS_FETCH_SUCCESS = 'EVENT_PARTICIPATIONS_FETCH_SUCCESS';
const EVENT_PARTICIPATIONS_FETCH_FAIL = 'EVENT_PARTICIPATIONS_FETCH_FAIL';
const EVENT_PARTICIPATIONS_FETCH_REQUEST = 'EVENT_PARTICIPATIONS_FETCH_REQUEST' as const;
const EVENT_PARTICIPATIONS_FETCH_SUCCESS = 'EVENT_PARTICIPATIONS_FETCH_SUCCESS' as const;
const EVENT_PARTICIPATIONS_FETCH_FAIL = 'EVENT_PARTICIPATIONS_FETCH_FAIL' as const;
const EVENT_PARTICIPATIONS_EXPAND_REQUEST = 'EVENT_PARTICIPATIONS_EXPAND_REQUEST';
const EVENT_PARTICIPATIONS_EXPAND_SUCCESS = 'EVENT_PARTICIPATIONS_EXPAND_SUCCESS';
const EVENT_PARTICIPATIONS_EXPAND_FAIL = 'EVENT_PARTICIPATIONS_EXPAND_FAIL';
const EVENT_PARTICIPATIONS_EXPAND_REQUEST = 'EVENT_PARTICIPATIONS_EXPAND_REQUEST' as const;
const EVENT_PARTICIPATIONS_EXPAND_SUCCESS = 'EVENT_PARTICIPATIONS_EXPAND_SUCCESS' as const;
const EVENT_PARTICIPATIONS_EXPAND_FAIL = 'EVENT_PARTICIPATIONS_EXPAND_FAIL' as const;
const EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST';
const EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS';
const EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL = 'EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL';
const EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST' as const;
const EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS' as const;
const EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL = 'EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL' as const;
const EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST';
const EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS';
const EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL';
const EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST' as const;
const EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS' as const;
const EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL' as const;
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST';
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS';
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL';
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST' as const;
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS' as const;
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL' as const;
const EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST = 'EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST';
const EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS';
const EVENT_PARTICIPATION_REQUEST_REJECT_FAIL = 'EVENT_PARTICIPATION_REQUEST_REJECT_FAIL';
const EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST = 'EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST' as const;
const EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS' as const;
const EVENT_PARTICIPATION_REQUEST_REJECT_FAIL = 'EVENT_PARTICIPATION_REQUEST_REJECT_FAIL' as const;
const EVENT_COMPOSE_CANCEL = 'EVENT_COMPOSE_CANCEL';
const EVENT_COMPOSE_CANCEL = 'EVENT_COMPOSE_CANCEL' as const;
const EVENT_FORM_SET = 'EVENT_FORM_SET';
const EVENT_FORM_SET = 'EVENT_FORM_SET' as const;
const RECENT_EVENTS_FETCH_REQUEST = 'RECENT_EVENTS_FETCH_REQUEST';
const RECENT_EVENTS_FETCH_SUCCESS = 'RECENT_EVENTS_FETCH_SUCCESS';
const RECENT_EVENTS_FETCH_FAIL = 'RECENT_EVENTS_FETCH_FAIL';
const JOINED_EVENTS_FETCH_REQUEST = 'JOINED_EVENTS_FETCH_REQUEST';
const JOINED_EVENTS_FETCH_SUCCESS = 'JOINED_EVENTS_FETCH_SUCCESS';
const JOINED_EVENTS_FETCH_FAIL = 'JOINED_EVENTS_FETCH_FAIL';
const RECENT_EVENTS_FETCH_REQUEST = 'RECENT_EVENTS_FETCH_REQUEST' as const;
const RECENT_EVENTS_FETCH_SUCCESS = 'RECENT_EVENTS_FETCH_SUCCESS' as const;
const RECENT_EVENTS_FETCH_FAIL = 'RECENT_EVENTS_FETCH_FAIL' as const;
const JOINED_EVENTS_FETCH_REQUEST = 'JOINED_EVENTS_FETCH_REQUEST' as const;
const JOINED_EVENTS_FETCH_SUCCESS = 'JOINED_EVENTS_FETCH_SUCCESS' as const;
const JOINED_EVENTS_FETCH_FAIL = 'JOINED_EVENTS_FETCH_FAIL' as const;
const noOp = () => new Promise(f => f(undefined));
@ -153,52 +152,21 @@ const changeEditEventLocation = (value: string | null) =>
};
const uploadEventBanner = (file: File, intl: IntlShape) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined;
(dispatch: AppDispatch) => {
let progress = 0;
dispatch(uploadEventBannerRequest());
if (maxImageSize && (file.size > maxImageSize)) {
const limit = formatBytes(maxImageSize);
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
toast.error(message);
dispatch(uploadEventBannerFail(true));
return;
}
resizeImage(file).then(file => {
const data = new FormData();
data.append('file', file);
// Account for disparity in size of original image and resized data
const onUploadProgress = ({ loaded }: any) => {
dispatch(uploadFile(
file,
intl,
(data) => dispatch(uploadEventBannerSuccess(data, file)),
(error) => dispatch(uploadEventBannerFail(error)),
({ loaded }: any) => {
progress = loaded;
dispatch(uploadEventBannerProgress(progress));
};
return dispatch(uploadMedia(data, onUploadProgress))
.then(({ status, data }) => {
// If server-side processing of the media attachment has not completed yet,
// poll the server until it is, before showing the media attachment as uploaded
if (status === 200) {
dispatch(uploadEventBannerSuccess(data, file));
} else if (status === 202) {
const poll = () => {
dispatch(fetchMedia(data.id)).then(({ status, data }) => {
if (status === 200) {
dispatch(uploadEventBannerSuccess(data, file));
} else if (status === 206) {
setTimeout(() => poll(), 1000);
}
}).catch(error => dispatch(uploadEventBannerFail(error)));
};
poll();
}
});
}).catch(error => dispatch(uploadEventBannerFail(error)));
},
));
};
const uploadEventBannerRequest = () => ({
@ -576,6 +544,13 @@ const cancelEventCompose = () => ({
type: EVENT_COMPOSE_CANCEL,
});
interface EventFormSetAction {
type: typeof EVENT_FORM_SET
status: ReducerStatus
text: string
location: Record<string, any>
}
const editEvent = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
const status = getState().statuses.get(id)!;
@ -612,7 +587,7 @@ const fetchRecentEvents = () =>
next: next ? next.uri : null,
});
}).catch(error => {
dispatch({ type: RECENT_EVENTS_FETCH_FAIL, error });
dispatch({ type: RECENT_EVENTS_FETCH_FAIL, error });
});
};
@ -633,10 +608,14 @@ const fetchJoinedEvents = () =>
next: next ? next.uri : null,
});
}).catch(error => {
dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error });
dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error });
});
};
type EventsAction =
| ReturnType<typeof cancelEventCompose>
| EventFormSetAction;
export {
LOCATION_SEARCH_REQUEST,
LOCATION_SEARCH_SUCCESS,
@ -743,4 +722,5 @@ export {
editEvent,
fetchRecentEvents,
fetchJoinedEvents,
type EventsAction,
};

View File

@ -1,8 +1,22 @@
import { defineMessages, type IntlShape } from 'react-intl';
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
import { formatBytes, getVideoDuration } from 'soapbox/utils/media';
import resizeImage from 'soapbox/utils/resize-image';
import api from '../api';
import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
const messages = defineMessages({
exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' },
exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' },
exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit, plural, one {# second} other {# seconds}})' },
});
const noOp = (e: any) => {};
@ -41,10 +55,78 @@ const uploadMedia = (data: FormData, onUploadProgress = noOp) =>
}
};
const uploadFile = (
file: File,
intl: IntlShape,
onSuccess: (data: APIEntity) => void = () => {},
onFail: (error: AxiosError | true) => void = () => {},
onProgress: (loaded: number) => void = () => {},
changeTotal: (value: number) => void = () => {},
) =>
async (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined;
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 isImage = file.type.match(/image.*/);
const isVideo = file.type.match(/video.*/);
const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(file) : 0;
if (isImage && maxImageSize && (file.size > maxImageSize)) {
const limit = formatBytes(maxImageSize);
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
toast.error(message);
onFail(true);
return;
} else if (isVideo && maxVideoSize && (file.size > maxVideoSize)) {
const limit = formatBytes(maxVideoSize);
const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit });
toast.error(message);
onFail(true);
return;
} else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) {
const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration });
toast.error(message);
onFail(true);
return;
}
// FIXME: Don't define const in loop
resizeImage(file).then(resized => {
const data = new FormData();
data.append('file', resized);
// Account for disparity in size of original image and resized data
changeTotal(resized.size - file.size);
return dispatch(uploadMedia(data, onProgress))
.then(({ status, data }) => {
// If server-side processing of the media attachment has not completed yet,
// poll the server until it is, before showing the media attachment as uploaded
if (status === 200) {
onSuccess(data);
} else if (status === 202) {
const poll = () => {
dispatch(fetchMedia(data.id)).then(({ status, data }) => {
if (status === 200) {
onSuccess(data);
} else if (status === 206) {
setTimeout(() => poll(), 1000);
}
}).catch(error => onFail(error));
};
poll();
}
});
}).catch(error => onFail(error));
};
export {
fetchMedia,
updateMedia,
uploadMediaV1,
uploadMediaV2,
uploadMedia,
uploadFile,
};

View File

@ -76,7 +76,7 @@ export interface IAccount {
actionAlignment?: 'center' | 'top'
actionIcon?: string
actionTitle?: string
/** Override other actions for specificity like mute/unmute. */
/** Override other actions for specificity like mute/unmute. */
actionType?: 'muting' | 'blocking' | 'follow_request'
avatarSize?: number
hidden?: boolean

View File

@ -35,7 +35,6 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
if (!item) return;
if (onClick) onClick();
if (item.to) {
event.preventDefault();
history.push(item.to);

View File

@ -92,9 +92,9 @@
@apply w-5 h-5 m-0;
}
/* Hide Markdown images (Pleroma) */
/* Markdown inline images (Pleroma) */
[data-markup] img:not(.emojione) {
@apply hidden;
@apply max-h-[500px] mx-auto rounded-sm;
}
/* User setting to underline links */

View File

@ -20,7 +20,7 @@ const messages = defineMessages({
export const checkComposeContent = (compose?: ReturnType<typeof ReducerCompose>) => {
return !!compose && [
compose.text.length > 0,
compose.editorState && compose.editorState.length > 0,
compose.spoiler_text.length > 0,
compose.media_attachments.size > 0,
compose.poll !== null,

View File

@ -20,7 +20,7 @@ interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
scrollKey: string
/** List of status IDs to display. */
statusIds: ImmutableOrderedSet<string>
/** Last _unfiltered_ status ID (maxId) for pagination. */
/** Last _unfiltered_ status ID (maxId) for pagination. */
lastStatusId?: string
/** Pinned statuses to show at the top of the feed. */
featuredStatusIds?: ImmutableOrderedSet<string>

View File

@ -5,16 +5,21 @@ import { joinPublicPath } from 'soapbox/utils/static';
interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
/** Unicode emoji character. */
emoji: string
emoji?: string
}
/** A single emoji image. */
const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
const { emoji, alt, src, ...rest } = props;
const codepoints = toCodePoints(removeVS16s(emoji));
const filename = codepoints.join('-');
if (!filename) return null;
let filename;
if (emoji) {
const codepoints = toCodePoints(removeVS16s(emoji));
filename = codepoints.join('-');
}
if (!filename && !src) return null;
return (
<img

View File

@ -1,7 +1,7 @@
import React from 'react';
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
import InlineSVG, { Props as InlineSVGProps } from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
interface ISvgIcon {
interface ISvgIcon extends InlineSVGProps {
/** Class name for the <svg> */
className?: string
/** Tooltip text for the icon. */

View File

@ -84,7 +84,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
type={revealed ? 'text' : type}
ref={ref}
className={clsx('text-base placeholder:text-gray-600 dark:placeholder:text-gray-600', {
'block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
'block w-full sm:text-sm ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
['normal', 'search'].includes(theme),
'text-gray-900 dark:text-gray-100': !props.disabled,
'text-gray-600': props.disabled,

View File

@ -15,7 +15,6 @@ const Portal: React.FC<IPortal> = ({ children }) => {
setIsRendered(true);
}, []);
if (!isRendered) {
return null;
}
@ -28,4 +27,4 @@ const Portal: React.FC<IPortal> = ({ children }) => {
);
};
export default Portal;
export default Portal;

View File

@ -99,7 +99,6 @@ const findElementPosition = (el: HTMLElement) => {
};
};
const getPointerPosition = (el: HTMLElement, event: MouseEvent & TouchEvent): Point => {
const box = findElementPosition(el);
const boxW = el.offsetWidth;
@ -121,4 +120,4 @@ const getPointerPosition = (el: HTMLElement, event: MouseEvent & TouchEvent): Po
};
};
export default Slider;
export default Slider;

View File

@ -1,13 +1,13 @@
import clsx from 'clsx';
import { List as ImmutableList } from 'immutable';
import React, { useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import { spring } from 'react-motion';
import { openModal } from 'soapbox/actions/modals';
import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon';
import IconButton from 'soapbox/components/icon-button';
import { HStack, IconButton } from 'soapbox/components/ui';
import Motion from 'soapbox/features/ui/util/optional-motion';
import { useAppDispatch } from 'soapbox/hooks';
import { Attachment } from 'soapbox/types/entities';
@ -57,6 +57,7 @@ export const MIMETYPE_ICONS: Record<string, string> = {
const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
delete: { id: 'upload_form.undo', defaultMessage: 'Delete' },
preview: { id: 'upload_form.preview', defaultMessage: 'Preview' },
});
interface IUpload {
@ -152,30 +153,34 @@ const Upload: React.FC<IUpload> = ({
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => (
<div
className={clsx('compose-form__upload-thumbnail', mediaType)}
className={clsx('compose-form__upload-thumbnail', mediaType)}
style={{
transform: `scale(${scale})`,
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined }}
>
<div className={clsx('compose-form__upload__actions', { active })}>
{onDelete && (
<IconButton
onClick={handleUndoClick}
src={require('@tabler/icons/x.svg')}
text={<FormattedMessage id='upload_form.undo' defaultMessage='Delete' />}
/>
)}
{/* Only display the "Preview" button for a valid attachment with a URL */}
<HStack className='absolute right-2 top-2 z-10' space={2}>
{(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && (
<IconButton
onClick={handleOpenModal}
src={require('@tabler/icons/zoom-in.svg')}
text={<FormattedMessage id='upload_form.preview' defaultMessage='Preview' />}
theme='dark'
className='hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
title={intl.formatMessage(messages.preview)}
/>
)}
</div>
{onDelete && (
<IconButton
onClick={handleUndoClick}
src={require('@tabler/icons/x.svg')}
theme='dark'
className='hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
title={intl.formatMessage(messages.delete)}
/>
)}
</HStack>
{onDescriptionChange && (
<div className={clsx('compose-form__upload-description', { active })}>

View File

@ -12,5 +12,4 @@ function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) {
};
}
export { parseEntitiesPath };
export { parseEntitiesPath };

View File

@ -37,7 +37,6 @@ const UserIndex: React.FC = () => {
updateQuery();
}, []);
const hasMore = items.count() < total && !!next;
const showLoading = isLoading && items.isEmpty();

View File

@ -2,7 +2,6 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { ChatContext } from 'soapbox/contexts/chat-context';
import { buildAccount } from 'soapbox/jest/factory';
import { normalizeChatMessage, normalizeInstance } from 'soapbox/normalizers';

View File

@ -5,7 +5,7 @@ import { __stub } from 'soapbox/api';
import { ChatContext } from 'soapbox/contexts/chat-context';
import { StatProvider } from 'soapbox/contexts/stat-context';
import chats from 'soapbox/jest/fixtures/chats.json';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
import ChatPane from '../chat-pane';

View File

@ -1,6 +1,7 @@
import clsx from 'clsx';
import { CLEAR_EDITOR_COMMAND, type LexicalEditor } from 'lexical';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, MessageDescriptor, useIntl } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { length } from 'stringz';
@ -17,6 +18,8 @@ import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
import { Button, HStack, Stack } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
import Bundle from 'soapbox/features/ui/components/bundle';
import { ComposeEditor } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector, useCompose, useDraggedFiles, useFeatures, useInstance, usePrevious } from 'soapbox/hooks';
import { isMobile } from 'soapbox/is-mobile';
@ -49,7 +52,6 @@ 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…' },
eventPlaceholder: { id: 'compose_form.event_placeholder', defaultMessage: 'Post to this event' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here (optional)' },
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
message: { id: 'compose_form.message', defaultMessage: 'Message' },
@ -75,12 +77,24 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
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 = configuration.getIn(['statuses', 'max_characters']) as number;
const scheduledStatusCount = useAppSelector((state) => state.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, group_id: groupId } = compose;
const {
spoiler,
spoiler_text: spoilerText,
privacy,
focusDate,
caretPosition,
is_submitting: isSubmitting,
is_changing_upload:
isChangingUpload,
is_uploading: isUploading,
schedule: scheduledAt,
group_id: groupId,
} = compose;
const prevSpoiler = usePrevious(spoiler);
const hasPoll = !!compose.poll;
@ -88,25 +102,16 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const anyMedia = compose.media_attachments.size > 0;
const [composeFocused, setComposeFocused] = useState(false);
const [text, setText] = useState('');
const firstRender = useRef(true);
const formRef = useRef<HTMLDivElement>(null);
const spoilerTextRef = useRef<AutosuggestInput>(null);
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
const editorRef = useRef<LexicalEditor>(null);
const { isDraggedOver } = useDraggedFiles(formRef);
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
dispatch(changeCompose(id, e.target.value));
};
const handleKeyDown: React.KeyboardEventHandler = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
handleSubmit();
e.preventDefault(); // Prevent bubbling to other ComposeForm instances
}
};
const getClickableArea = () => {
return clickableAreaRef ? clickableAreaRef.current : formRef.current;
};
@ -141,11 +146,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
};
const handleSubmit = (e?: React.FormEvent<Element>) => {
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));
}
dispatch(changeCompose(id, text));
// Submit disabled:
const fulltext = [spoilerText, countableText(text)].join('');
@ -159,6 +160,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
}
dispatch(submitCompose(id, history));
editorRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
};
const onSuggestionsClearRequested = () => {
@ -169,10 +171,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
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']));
};
@ -249,18 +247,34 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
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 = '';
let publishIcon: string | undefined;
let textareaPlaceholder: MessageDescriptor;
const composeModifiers = !condensed && (
<Stack space={4} className='compose-form__modifiers'>
<UploadForm composeId={id} />
<PollForm composeId={id} />
<SpoilerInput
composeId={id}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSpoilerSuggestionSelected}
ref={spoilerTextRef}
/>
<ScheduleFormContainer composeId={id} />
</Stack>
);
let publishText: string | JSX.Element = '';
let publishIcon: string | undefined = undefined;
if (isEditing) {
publishText = intl.formatMessage(messages.saveChanges);
} else if (privacy === 'direct') {
publishText = intl.formatMessage(messages.message);
publishIcon = require('@tabler/icons/mail.svg');
publishText = intl.formatMessage(messages.message);
} else if (privacy === 'private') {
publishText = intl.formatMessage(messages.publish);
publishIcon = require('@tabler/icons/lock.svg');
publishText = intl.formatMessage(messages.publish);
} else {
publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
@ -269,14 +283,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
publishText = intl.formatMessage(messages.schedule);
}
if (event) {
textareaPlaceholder = messages.eventPlaceholder;
} else if (hasPoll) {
textareaPlaceholder = messages.pollPlaceholder;
} else {
textareaPlaceholder = messages.placeholder;
}
return (
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
{scheduledStatusCount > 0 && !event && !group && (
@ -306,42 +312,26 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
{!shouldCondense && !event && !group && <ReplyMentions composeId={id} />}
<AutosuggestTextarea
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
placeholder={intl.formatMessage(textareaPlaceholder)}
disabled={disabled}
value={text}
onChange={handleChange}
suggestions={suggestions}
onKeyDown={handleKeyDown}
onFocus={handleComposeFocus}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSuggestionSelected}
onPaste={onPaste}
autoFocus={shouldAutoFocus}
condensed={condensed}
id='compose-textarea'
>
{
!condensed && (
<Stack space={4} className='compose-form__modifiers'>
<UploadForm composeId={id} />
<PollForm composeId={id} />
<SpoilerInput
composeId={id}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSpoilerSuggestionSelected}
ref={spoilerTextRef}
/>
<ScheduleFormContainer composeId={id} />
</Stack>
)
}
</AutosuggestTextarea>
<div>
<Bundle fetchComponent={ComposeEditor}>
{(Component: any) => (
<Component
ref={editorRef}
className='mt-2'
composeId={id}
condensed={condensed}
eventDiscussion={!!event}
autoFocus={shouldAutoFocus}
hasPoll={hasPoll}
handleSubmit={handleSubmit}
onChange={setText}
onFocus={handleComposeFocus}
onPaste={onPaste}
/>
)}
</Bundle>
{composeModifiers}
</div>
<QuotedStatusContainer composeId={id} />
@ -362,7 +352,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
</HStack>
)}
<Button type='submit' theme='primary' text={publishText} icon={publishIcon} disabled={disabledButton} />
<Button type='submit' theme='primary' icon={publishIcon} text={publishText} disabled={disabledButton} />
</HStack>
</div>
</Stack>

View File

@ -10,7 +10,7 @@ const messages = defineMessages({
upload: { id: 'upload_button.label', defaultMessage: 'Add media attachment' },
});
const onlyImages = (types: ImmutableList<string>) => {
export const onlyImages = (types: ImmutableList<string>) => {
return Boolean(types && types.every(type => type.startsWith('image/')));
};

View File

@ -1,6 +1,7 @@
import clsx from 'clsx';
import React from 'react';
import { HStack } from 'soapbox/components/ui';
import { useCompose } from 'soapbox/hooks';
import Upload from './upload';
@ -14,19 +15,16 @@ interface IUploadForm {
const UploadForm: React.FC<IUploadForm> = ({ composeId }) => {
const mediaIds = useCompose(composeId).media_attachments.map((item: AttachmentEntity) => item.id);
const classes = clsx('compose-form__uploads-wrapper', {
'contains-media': mediaIds.size !== 0,
});
return (
<div className='compose-form__upload-wrapper'>
<div className='overflow-hidden'>
<UploadProgress composeId={composeId} />
<div className={classes}>
<HStack wrap className={clsx('overflow-hidden', mediaIds.size !== 0 && 'p-1')}>
{mediaIds.map((id: string) => (
<Upload id={id} key={id} composeId={composeId} />
))}
</div>
</HStack>
</div>
);
};

View File

@ -0,0 +1,17 @@
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,177 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the `/src/features/compose/editor` directory.
*/
import { AutoLinkPlugin, createLinkMatcherWithRegExp } from '@lexical/react/LexicalAutoLinkPlugin';
import { ClearEditorPlugin } from '@lexical/react/LexicalClearEditorPlugin';
import { LexicalComposer, InitialConfigType } from '@lexical/react/LexicalComposer';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
import { HashtagPlugin } from '@lexical/react/LexicalHashtagPlugin';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin';
import clsx from 'clsx';
import { $createParagraphNode, $createTextNode, $getRoot, type LexicalEditor } from 'lexical';
import React, { useMemo, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useAppDispatch } from 'soapbox/hooks';
import { useNodes } from './nodes';
import AutosuggestPlugin from './plugins/autosuggest-plugin';
import FocusPlugin from './plugins/focus-plugin';
import MentionPlugin from './plugins/mention-plugin';
import RefPlugin from './plugins/ref-plugin';
import StatePlugin from './plugins/state-plugin';
const LINK_MATCHERS = [
createLinkMatcherWithRegExp(
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/,
(text) => text.startsWith('http') ? text : `https://${text}`,
),
];
interface IComposeEditor {
className?: string
placeholderClassName?: string
composeId: string
condensed?: boolean
eventDiscussion?: boolean
hasPoll?: boolean
autoFocus?: boolean
handleSubmit?(): void
onPaste?(files: FileList): void
onChange?(text: string): void
onFocus?: React.FocusEventHandler<HTMLDivElement>
placeholder?: JSX.Element | string
}
const theme: InitialConfigType['theme'] = {
hashtag: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue',
mention: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue',
link: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue',
text: {
bold: 'font-bold',
code: 'font-mono',
italic: 'italic',
strikethrough: 'line-through',
underline: 'underline',
underlineStrikethrough: 'underline-line-through',
},
heading: {
h1: 'text-2xl font-bold',
h2: 'text-xl font-bold',
h3: 'text-lg font-semibold',
},
};
const ComposeEditor = React.forwardRef<LexicalEditor, IComposeEditor>(({
className,
placeholderClassName,
composeId,
condensed,
eventDiscussion,
hasPoll,
autoFocus,
handleSubmit,
onChange,
onFocus,
onPaste,
placeholder,
}, ref) => {
const dispatch = useAppDispatch();
const nodes = useNodes();
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
const initialConfig = useMemo<InitialConfigType>(() => ({
namespace: 'ComposeForm',
onError: console.error,
nodes,
theme,
editorState: dispatch((_, getState) => {
const state = getState();
const compose = state.compose.get(composeId);
if (!compose) return;
if (compose.editorState) {
return compose.editorState;
}
return () => {
const paragraph = $createParagraphNode();
const textNode = $createTextNode(compose.text);
paragraph.append(textNode);
$getRoot()
.clear()
.append(paragraph);
};
}),
}), []);
const handlePaste: React.ClipboardEventHandler<HTMLDivElement> = (e) => {
if (onPaste && e.clipboardData && e.clipboardData.files.length === 1) {
onPaste(e.clipboardData.files);
e.preventDefault();
}
};
let textareaPlaceholder = placeholder || <FormattedMessage id='compose_form.placeholder' defaultMessage="What's on your mind?" />;
if (eventDiscussion) {
textareaPlaceholder = <FormattedMessage id='compose_form.event_placeholder' defaultMessage='Post to this event' />;
} else if (hasPoll) {
textareaPlaceholder = <FormattedMessage id='compose_form.poll_placeholder' defaultMessage='Add a poll topic…' />;
}
return (
<LexicalComposer initialConfig={initialConfig}>
<div className={clsx('relative', className)}>
<PlainTextPlugin
contentEditable={
<div onFocus={onFocus} onPaste={handlePaste}>
<ContentEditable
className={clsx('relative z-10 text-[1rem] outline-none transition-[min-height] motion-reduce:transition-none', {
'min-h-[39px]': condensed,
'min-h-[99px]': !condensed,
})}
/>
</div>
}
placeholder={(
<div
className={clsx(
'pointer-events-none absolute top-0 select-none text-[1rem] text-gray-600 dark:placeholder:text-gray-600',
placeholderClassName,
)}
>
{textareaPlaceholder}
</div>
)}
ErrorBoundary={LexicalErrorBoundary}
/>
<OnChangePlugin onChange={(_, editor) => {
onChange?.(editor.getEditorState().read(() => $getRoot().getTextContent()));
}}
/>
<HistoryPlugin />
<HashtagPlugin />
<MentionPlugin />
<AutosuggestPlugin composeId={composeId} suggestionsHidden={suggestionsHidden} setSuggestionsHidden={setSuggestionsHidden} />
<AutoLinkPlugin matchers={LINK_MATCHERS} />
<StatePlugin composeId={composeId} handleSubmit={handleSubmit} />
<FocusPlugin autoFocus={autoFocus} />
<ClearEditorPlugin />
<RefPlugin ref={ref} />
</div>
</LexicalComposer>
);
});
export default ComposeEditor;

View File

@ -0,0 +1,101 @@
import { $applyNodeReplacement, DecoratorNode } from 'lexical';
import React from 'react';
import { Emoji } from 'soapbox/components/ui';
import type {
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
type SerializedEmojiNode = Spread<{
name: string
src: string
type: 'emoji'
version: 1
}, SerializedLexicalNode>;
class EmojiNode extends DecoratorNode<JSX.Element> {
__name: string;
__src: string;
static getType(): 'emoji' {
return 'emoji';
}
static clone(node: EmojiNode): EmojiNode {
return new EmojiNode(node.__name, node.__src);
}
constructor(name: string, src: string, key?: NodeKey) {
super(key);
this.__name = name;
this.__src = src;
}
createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement('span');
const theme = config.theme;
const className = theme.emoji;
if (className !== undefined) {
span.className = className;
}
return span;
}
updateDOM(): false {
return false;
}
exportDOM(): DOMExportOutput {
const element = document.createElement('img');
element.setAttribute('src', this.__src);
element.setAttribute('alt', this.__name);
element.classList.add('h-4', 'w-4');
return { element };
}
static importJSON(serializedNode: SerializedEmojiNode): EmojiNode {
const { name, src } =
serializedNode;
const node = $createEmojiNode(name, src);
return node;
}
exportJSON(): SerializedEmojiNode {
return {
name: this.__name,
src: this.__src,
type: 'emoji',
version: 1,
};
}
canInsertTextBefore(): boolean {
return false;
}
isTextEntity(): true {
return true;
}
decorate(): JSX.Element {
return (
<Emoji src={this.__src} alt={this.__name} className='emojione h-4 w-4' />
);
}
}
const $createEmojiNode = (name = '', src: string): EmojiNode => $applyNodeReplacement(new EmojiNode(name, src));
const $isEmojiNode = (
node: LexicalNode | null | undefined,
): node is EmojiNode => node instanceof EmojiNode;
export { EmojiNode, $createEmojiNode, $isEmojiNode };

View File

@ -0,0 +1,359 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
import { mergeRegister } from '@lexical/utils';
import clsx from 'clsx';
import { List as ImmutableList } from 'immutable';
import {
$getNodeByKey,
$getSelection,
$isNodeSelection,
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
DRAGSTART_COMMAND,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import * as React from 'react';
import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { HStack, IconButton } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import { normalizeAttachment } from 'soapbox/normalizers';
import { $isImageNode } from './image-node';
import type {
GridSelection,
LexicalEditor,
NodeKey,
NodeSelection,
RangeSelection,
} from 'lexical';
const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
});
const imageCache = new Set();
const useSuspenseImage = (src: string) => {
if (!imageCache.has(src)) {
throw new Promise((resolve) => {
const img = new Image();
img.src = src;
img.onload = () => {
imageCache.add(src);
resolve(null);
};
});
}
};
const LazyImage = ({
altText,
className,
imageRef,
src,
}: {
altText: string
className: string | null
imageRef: {current: null | HTMLImageElement}
src: string
}): JSX.Element => {
useSuspenseImage(src);
return (
<img
className={className || undefined}
src={src}
alt={altText}
ref={imageRef}
draggable='false'
/>
);
};
const ImageComponent = ({
src,
altText,
nodeKey,
}: {
altText: string
nodeKey: NodeKey
src: string
}): JSX.Element => {
const intl = useIntl();
const dispatch = useAppDispatch();
const imageRef = useRef<null | HTMLImageElement>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey);
const [editor] = useLexicalComposerContext();
const [selection, setSelection] = useState<
RangeSelection | NodeSelection | GridSelection | null
>(null);
const activeEditorRef = useRef<LexicalEditor | null>(null);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [dirtyDescription, setDirtyDescription] = useState<string | null>(null);
const deleteNode = useCallback(
() => {
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isImageNode(node)) {
node.remove();
}
});
},
[nodeKey],
);
const previewImage = () => {
const image = normalizeAttachment({
type: 'image',
url: src,
altText,
});
dispatch(openModal('MEDIA', { media: ImmutableList.of(image), index: 0 }));
};
const onDelete = useCallback(
(payload: KeyboardEvent) => {
if (isSelected && $isNodeSelection($getSelection())) {
const event: KeyboardEvent = payload;
event.preventDefault();
deleteNode();
}
return false;
},
[isSelected, nodeKey],
);
const onEnter = useCallback(
(event: KeyboardEvent) => {
const latestSelection = $getSelection();
const buttonElem = buttonRef.current;
if (isSelected && $isNodeSelection(latestSelection) && latestSelection.getNodes().length === 1) {
if (buttonElem !== null && buttonElem !== document.activeElement) {
event.preventDefault();
buttonElem.focus();
return true;
}
}
return false;
},
[isSelected],
);
const onEscape = useCallback(
(event: KeyboardEvent) => {
if (buttonRef.current === event.target) {
$setSelection(null);
editor.update(() => {
setSelected(true);
const parentRootElement = editor.getRootElement();
if (parentRootElement !== null) {
parentRootElement.focus();
}
});
return true;
}
return false;
},
[editor, setSelected],
);
const handleKeyDown: React.KeyboardEventHandler = (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleInputBlur();
}
};
const handleInputBlur = () => {
setFocused(false);
if (dirtyDescription !== null) {
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isImageNode(node)) {
node.setAltText(dirtyDescription);
}
setDirtyDescription(null);
});
}
};
const handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
setDirtyDescription(e.target.value);
};
const handleMouseEnter = () => {
setHovered(true);
};
const handleMouseLeave = () => {
setHovered(false);
};
const handleInputFocus = () => {
setFocused(true);
};
const handleClick = () => {
setFocused(true);
};
useEffect(() => {
let isMounted = true;
const unregister = mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
if (isMounted) {
setSelection(editorState.read(() => $getSelection()));
}
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_, activeEditor) => {
activeEditorRef.current = activeEditor;
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<MouseEvent>(
CLICK_COMMAND,
(payload) => {
const event = payload;
if (event.target === imageRef.current) {
if (event.shiftKey) {
setSelected(!isSelected);
} else {
clearSelection();
setSelected(true);
}
return true;
}
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
DRAGSTART_COMMAND,
(event) => {
if (event.target === imageRef.current) {
// TODO This is just a temporary workaround for FF to behave like other browsers.
// Ideally, this handles drag & drop too (and all browsers).
event.preventDefault();
return true;
}
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_DELETE_COMMAND,
onDelete,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
onDelete,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_LOW),
editor.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_LOW,
),
);
return () => {
isMounted = false;
unregister();
};
}, [
clearSelection,
editor,
isSelected,
nodeKey,
onDelete,
onEnter,
onEscape,
setSelected,
]);
const active = hovered || focused;
const description = dirtyDescription || (dirtyDescription !== '' && altText) || '';
const draggable = isSelected && $isNodeSelection(selection);
return (
<Suspense fallback={null}>
<>
<div className='relative' draggable={draggable} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={handleClick} role='button'>
<HStack className='absolute right-2 top-2 z-10' space={2}>
<IconButton
onClick={previewImage}
src={require('@tabler/icons/zoom-in.svg')}
theme='dark'
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
/>
<IconButton
onClick={deleteNode}
src={require('@tabler/icons/x.svg')}
theme='dark'
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
/>
</HStack>
<div className={clsx('compose-form__upload-description', { active })}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<textarea
placeholder={intl.formatMessage(messages.description)}
value={description}
onFocus={handleInputFocus}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
/>
</label>
</div>
<LazyImage
className={
clsx('cursor-default', {
'select-none': isSelected,
'cursor-grab active:cursor-grabbing': isSelected && $isNodeSelection(selection),
})
}
src={src}
altText={altText}
imageRef={imageRef}
/>
</div>
</>
</Suspense>
);
};
export default ImageComponent;

View File

@ -0,0 +1,179 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { $applyNodeReplacement, DecoratorNode } from 'lexical';
import * as React from 'react';
import { Suspense } from 'react';
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
const ImageComponent = React.lazy(() => import('./image-component'));
interface ImagePayload {
altText?: string
key?: NodeKey
src: string
}
const convertImageElement = (domNode: Node): null | DOMConversionOutput => {
if (domNode instanceof HTMLImageElement) {
const { alt: altText, src } = domNode;
const node = $createImageNode({ altText, src });
return { node };
}
return null;
};
type SerializedImageNode = Spread<
{
altText: string
src: string
},
SerializedLexicalNode
>;
class ImageNode extends DecoratorNode<JSX.Element> {
__src: string;
__altText: string;
static getType(): string {
return 'image';
}
static clone(node: ImageNode): ImageNode {
return new ImageNode(
node.__src,
node.__altText,
node.__key,
);
}
static importJSON(serializedNode: SerializedImageNode): ImageNode {
const { altText, src } =
serializedNode;
const node = $createImageNode({
altText,
src,
});
return node;
}
exportDOM(): DOMExportOutput {
const element = document.createElement('img');
element.setAttribute('src', this.__src);
element.setAttribute('alt', this.__altText);
return { element };
}
static importDOM(): DOMConversionMap | null {
return {
img: (node: Node) => ({
conversion: convertImageElement,
priority: 0,
}),
};
}
constructor(
src: string,
altText: string,
key?: NodeKey,
) {
super(key);
this.__src = src;
this.__altText = altText;
}
exportJSON(): SerializedImageNode {
return {
altText: this.getAltText(),
src: this.getSrc(),
type: 'image',
version: 1,
};
}
// View
createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement('span');
const theme = config.theme;
const className = theme.image;
if (className !== undefined) {
span.className = className;
}
return span;
}
updateDOM(): false {
return false;
}
getSrc(): string {
return this.__src;
}
getAltText(): string {
return this.__altText;
}
setAltText(altText: string): void {
const writable = this.getWritable();
if (altText !== undefined) {
writable.__altText = altText;
}
}
decorate(): JSX.Element {
return (
<Suspense fallback={null}>
<ImageComponent
src={this.__src}
altText={this.__altText}
nodeKey={this.getKey()}
/>
</Suspense>
);
}
}
const $createImageNode = ({
altText = '',
src,
key,
}: ImagePayload): ImageNode => {
return $applyNodeReplacement(
new ImageNode(
src,
altText,
key,
),
);
};
const $isImageNode = (
node: LexicalNode | null | undefined,
): node is ImageNode => node instanceof ImageNode;
export {
type ImagePayload,
type SerializedImageNode,
ImageNode,
$createImageNode,
$isImageNode,
};

View File

@ -0,0 +1,51 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { CodeHighlightNode, CodeNode } from '@lexical/code';
import { HashtagNode } from '@lexical/hashtag';
import { AutoLinkNode, LinkNode } from '@lexical/link';
import { ListItemNode, ListNode } from '@lexical/list';
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode';
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { useFeatures, useInstance } from 'soapbox/hooks';
import { EmojiNode } from './emoji-node';
import { ImageNode } from './image-node';
import { MentionNode } from './mention-node';
import type { Klass, LexicalNode } from 'lexical';
const useNodes = () => {
const features = useFeatures();
const instance = useInstance();
const nodes: Array<Klass<LexicalNode>> = [
AutoLinkNode,
HashtagNode,
EmojiNode,
MentionNode,
];
if (features.richText) {
nodes.push(
CodeHighlightNode,
CodeNode,
HorizontalRuleNode,
LinkNode,
ListItemNode,
ListNode,
QuoteNode,
);
}
if (instance.pleroma.getIn(['metadata', 'markup', 'allow_headings'])) nodes.push(HeadingNode);
if (instance.pleroma.getIn(['metadata', 'markup', 'allow_inline_images'])) nodes.push(ImageNode);
return nodes;
};
export { useNodes };

View File

@ -0,0 +1,69 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { addClassNamesToElement } from '@lexical/utils';
import { $applyNodeReplacement, TextNode } from 'lexical';
import type {
EditorConfig,
LexicalNode,
NodeKey,
SerializedTextNode,
} from 'lexical';
class MentionNode extends TextNode {
static getType(): string {
return 'mention';
}
static clone(node: MentionNode): MentionNode {
return new MentionNode(node.__text, node.__key);
}
constructor(text: string, key?: NodeKey) {
super(text, key);
}
createDOM(config: EditorConfig): HTMLElement {
const element = super.createDOM(config);
addClassNamesToElement(element, config.theme.mention);
return element;
}
static importJSON(serializedNode: SerializedTextNode): MentionNode {
const node = $createMentionNode(serializedNode.text);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
}
exportJSON(): SerializedTextNode {
return {
...super.exportJSON(),
type: 'mention',
};
}
canInsertTextBefore(): boolean {
return false;
}
isTextEntity(): true {
return true;
}
}
const $createMentionNode = (text = ''): MentionNode => $applyNodeReplacement(new MentionNode(text));
const $isMentionNode = (
node: LexicalNode | null | undefined,
): node is MentionNode => node instanceof MentionNode;
export { MentionNode, $createMentionNode, $isMentionNode };

View File

@ -0,0 +1,69 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { addClassNamesToElement } from '@lexical/utils';
import { $applyNodeReplacement, TextNode } from 'lexical';
import type {
EditorConfig,
LexicalNode,
NodeKey,
SerializedTextNode,
} from 'lexical';
class MentionNode extends TextNode {
static getType(): string {
return 'mention';
}
static clone(node: MentionNode): MentionNode {
return new MentionNode(node.__text, node.__key);
}
constructor(text: string, key?: NodeKey) {
super(text, key);
}
createDOM(config: EditorConfig): HTMLElement {
const element = super.createDOM(config);
addClassNamesToElement(element, config.theme.mention);
return element;
}
static importJSON(serializedNode: SerializedTextNode): MentionNode {
const node = $createMentionNode(serializedNode.text);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
}
exportJSON(): SerializedTextNode {
return {
...super.exportJSON(),
type: 'mention',
};
}
canInsertTextBefore(): boolean {
return false;
}
isTextEntity(): true {
return true;
}
}
const $createMentionNode = (text = ''): MentionNode => $applyNodeReplacement(new MentionNode(text));
const $isMentionNode = (
node: LexicalNode | null | undefined,
): node is MentionNode => node instanceof MentionNode;
export { MentionNode, $createMentionNode, $isMentionNode };

View File

@ -0,0 +1,561 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { mergeRegister } from '@lexical/utils';
import clsx from 'clsx';
import {
$getSelection,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_LOW,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND,
KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND,
KEY_TAB_COMMAND,
LexicalEditor,
RangeSelection,
} from 'lexical';
import React, {
MutableRefObject,
ReactPortal,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import ReactDOM from 'react-dom';
import { clearComposeSuggestions, fetchComposeSuggestions } from 'soapbox/actions/compose';
import { useEmoji } from 'soapbox/actions/emojis';
import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji';
import { isNativeEmoji } from 'soapbox/features/emoji';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import { selectAccount } from 'soapbox/selectors';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import AutosuggestAccount from '../../components/autosuggest-account';
import { $createEmojiNode } from '../nodes/emoji-node';
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
type QueryMatch = {
leadOffset: number
matchingString: string
};
type Resolution = {
match: QueryMatch
getRect: () => DOMRect
};
type MenuRenderFn = (
anchorElementRef: MutableRefObject<HTMLElement | null>,
) => ReactPortal | JSX.Element | null;
const tryToPositionRange = (leadOffset: number, range: Range): boolean => {
const domSelection = window.getSelection();
if (domSelection === null || !domSelection.isCollapsed) {
return false;
}
const anchorNode = domSelection.anchorNode;
const startOffset = leadOffset;
const endOffset = domSelection.anchorOffset;
if (!anchorNode || !endOffset) {
return false;
}
try {
range.setStart(anchorNode, startOffset);
range.setEnd(anchorNode, endOffset);
} catch (error) {
return false;
}
return true;
};
const isSelectionOnEntityBoundary = (
editor: LexicalEditor,
offset: number,
): boolean => {
if (offset !== 0) {
return false;
}
return editor.getEditorState().read(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const anchor = selection.anchor;
const anchorNode = anchor.getNode();
const prevSibling = anchorNode.getPreviousSibling();
return $isTextNode(prevSibling) && prevSibling.isTextEntity();
}
return false;
});
};
const startTransition = (callback: () => void) => {
if (React.startTransition) {
React.startTransition(callback);
} else {
callback();
}
};
// Got from https://stackoverflow.com/a/42543908/2013580
const getScrollParent = (
element: HTMLElement,
includeHidden: boolean,
): HTMLElement | HTMLBodyElement => {
let style = getComputedStyle(element);
const excludeStaticParent = style.position === 'absolute';
const overflowRegex = includeHidden
? /(auto|scroll|hidden)/
: /(auto|scroll)/;
if (style.position === 'fixed') {
return document.body;
}
for (let parent: HTMLElement | null = element; (parent = parent.parentElement);) {
style = getComputedStyle(parent);
if (excludeStaticParent && style.position === 'static') {
continue;
}
if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) {
return parent;
}
}
return document.body;
};
const isTriggerVisibleInNearestScrollContainer = (
targetElement: HTMLElement,
containerElement: HTMLElement,
): boolean => {
const tRect = targetElement.getBoundingClientRect();
const cRect = containerElement.getBoundingClientRect();
return tRect.top > cRect.top && tRect.top < cRect.bottom;
};
// Reposition the menu on scroll, window resize, and element resize.
const useDynamicPositioning = (
resolution: Resolution | null,
targetElement: HTMLElement | null,
onReposition: () => void,
onVisibilityChange?: (isInView: boolean) => void,
) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (targetElement && resolution) {
const rootElement = editor.getRootElement();
const rootScrollParent =
rootElement
? getScrollParent(rootElement, false)
: document.body;
let ticking = false;
let previousIsInView = isTriggerVisibleInNearestScrollContainer(
targetElement,
rootScrollParent,
);
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
onReposition();
ticking = false;
});
ticking = true;
}
const isInView = isTriggerVisibleInNearestScrollContainer(
targetElement,
rootScrollParent,
);
if (isInView !== previousIsInView) {
previousIsInView = isInView;
if (onVisibilityChange) {
onVisibilityChange(isInView);
}
}
};
const resizeObserver = new ResizeObserver(onReposition);
window.addEventListener('resize', onReposition);
document.addEventListener('scroll', handleScroll, {
capture: true,
passive: true,
});
resizeObserver.observe(targetElement);
return () => {
resizeObserver.unobserve(targetElement);
window.removeEventListener('resize', onReposition);
document.removeEventListener('scroll', handleScroll);
};
}
}, [targetElement, editor, onVisibilityChange, onReposition, resolution]);
};
const LexicalPopoverMenu = ({ anchorElementRef, menuRenderFn }: {
anchorElementRef: MutableRefObject<HTMLElement>
menuRenderFn: MenuRenderFn
}): JSX.Element | null => menuRenderFn(anchorElementRef);
const useMenuAnchorRef = (
resolution: Resolution | null,
setResolution: (r: Resolution | null) => void,
): MutableRefObject<HTMLElement> => {
const [editor] = useLexicalComposerContext();
const anchorElementRef = useRef<HTMLElement>(document.createElement('div'));
const positionMenu = useCallback(() => {
const rootElement = editor.getRootElement();
const containerDiv = anchorElementRef.current;
if (rootElement !== null && resolution !== null) {
const { left, top, width, height } = resolution.getRect();
containerDiv.style.top = `${top + window.scrollY}px`;
containerDiv.style.left = `${left + window.scrollX}px`;
containerDiv.style.height = `${height}px`;
containerDiv.style.width = `${width}px`;
if (!containerDiv.isConnected) {
containerDiv.setAttribute('aria-label', 'Typeahead menu');
containerDiv.setAttribute('id', 'typeahead-menu');
containerDiv.setAttribute('role', 'listbox');
containerDiv.style.display = 'block';
containerDiv.style.position = 'absolute';
document.body.append(containerDiv);
}
anchorElementRef.current = containerDiv;
rootElement.setAttribute('aria-controls', 'typeahead-menu');
}
}, [editor, resolution]);
useEffect(() => {
const rootElement = editor.getRootElement();
if (resolution !== null) {
positionMenu();
return () => {
if (rootElement !== null) {
rootElement.removeAttribute('aria-controls');
}
const containerDiv = anchorElementRef.current;
if (containerDiv !== null && containerDiv.isConnected) {
containerDiv.remove();
}
};
}
}, [editor, positionMenu, resolution]);
const onVisibilityChange = useCallback(
(isInView: boolean) => {
if (resolution !== null) {
if (!isInView) {
setResolution(null);
}
}
},
[resolution, setResolution],
);
useDynamicPositioning(
resolution,
anchorElementRef.current,
positionMenu,
onVisibilityChange,
);
return anchorElementRef;
};
type AutosuggestPluginProps = {
composeId: string
suggestionsHidden: boolean
setSuggestionsHidden: (value: boolean) => void
};
const AutosuggestPlugin = ({
composeId,
suggestionsHidden,
setSuggestionsHidden,
}: AutosuggestPluginProps): JSX.Element | null => {
const { suggestions } = useCompose(composeId);
const dispatch = useAppDispatch();
const [editor] = useLexicalComposerContext();
const [resolution, setResolution] = useState<Resolution | null>(null);
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
const anchorElementRef = useMenuAnchorRef(
resolution,
setResolution,
);
const handleSelectSuggestion: React.MouseEventHandler<HTMLDivElement> = (e) => {
e.preventDefault();
const index = Number(e.currentTarget.getAttribute('data-index'));
return onSelectSuggestion(index);
};
const onSelectSuggestion = (index: number) => {
const suggestion = suggestions.get(index) as AutoSuggestion;
editor.update(() => {
dispatch((dispatch, getState) => {
const state = editor.getEditorState();
const node = (state._selection as RangeSelection)?.anchor?.getNode();
if (typeof suggestion === 'object') {
if (!suggestion.id) return;
dispatch(useEmoji(suggestion)); // eslint-disable-line react-hooks/rules-of-hooks
const { leadOffset, matchingString } = resolution!.match;
if (isNativeEmoji(suggestion)) {
node.spliceText(leadOffset - 1, matchingString.length, `${suggestion.native} `, true);
} else {
const completion = suggestion.colons;
let emojiText;
if (leadOffset === 1) emojiText = node;
else [, emojiText] = node.splitText(leadOffset - 1);
[emojiText] = emojiText.splitText(matchingString.length);
emojiText?.replace($createEmojiNode(completion, suggestion.imageUrl));
}
} else if (suggestion[0] === '#') {
node.setTextContent(`${suggestion} `);
node.select();
} else {
const content = selectAccount(getState(), suggestion)!.acct;
node.setTextContent(`@${content} `);
node.select();
}
dispatch(clearComposeSuggestions(composeId));
});
});
};
const getQueryTextForSearch = (editor: LexicalEditor) => {
const state = editor.getEditorState();
const node = (state._selection as RangeSelection)?.anchor?.getNode();
if (!node) return null;
if (['mention', 'hashtag'].includes(node.getType())) {
const matchingString = node.getTextContent();
return { leadOffset: 0, matchingString };
}
if (node.getType() === 'text') {
const [leadOffset, matchingString] = textAtCursorMatchesToken(node.getTextContent(), (state._selection as RangeSelection)?.anchor?.offset, [':']);
if (!leadOffset || !matchingString) return null;
return { leadOffset, matchingString };
}
return null;
};
const renderSuggestion = (suggestion: AutoSuggestion, i: number) => {
let inner: string | JSX.Element;
let key: React.Key;
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else if (suggestion[0] === '#') {
inner = suggestion;
key = suggestion;
} else {
inner = <AutosuggestAccount id={suggestion} />;
key = suggestion;
}
return (
<div
role='button'
tabIndex={0}
key={key}
data-index={i}
className={clsx({
'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true,
'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion,
})}
onMouseDown={handleSelectSuggestion}
>
{inner}
</div>
);
};
const closeTypeahead = useCallback(() => {
setResolution(null);
}, [resolution]);
const openTypeahead = useCallback(
(res: Resolution) => {
setResolution(res);
},
[resolution],
);
useEffect(() => {
const updateListener = () => {
editor.getEditorState().read(() => {
const range = document.createRange();
const match = getQueryTextForSearch(editor);
const nativeSelection = window.getSelection();
if (!match || nativeSelection?.anchorOffset !== nativeSelection?.focusOffset) {
closeTypeahead();
return;
}
dispatch(fetchComposeSuggestions(composeId, match.matchingString.trim()));
if (!isSelectionOnEntityBoundary(editor, match.leadOffset)) {
const isRangePositioned = tryToPositionRange(match.leadOffset, range);
if (isRangePositioned !== null) {
startTransition(() =>
openTypeahead({
getRect: () => range.getBoundingClientRect(),
match,
}),
);
return;
}
}
closeTypeahead();
});
};
const removeUpdateListener = editor.registerUpdateListener(updateListener);
return () => {
removeUpdateListener();
};
}, [
editor,
resolution,
closeTypeahead,
openTypeahead,
]);
useEffect(() => {
if (suggestions && suggestions.size > 0) setSuggestionsHidden(false);
}, [suggestions]);
useEffect(() => {
if (resolution !== null && !suggestionsHidden && !suggestions.isEmpty()) {
const handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!editor._rootElement?.contains(target) && !anchorElementRef.current.contains(target)) {
setResolution(null);
}
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}
}, [resolution, suggestionsHidden, suggestions.isEmpty()]);
useEffect(() => {
if (resolution === null) return;
return mergeRegister(
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_UP_COMMAND,
(payload) => {
const event = payload;
if (suggestions !== null && suggestions.size && selectedSuggestion !== null) {
const newSelectedSuggestion = selectedSuggestion !== 0 ? selectedSuggestion - 1 : suggestions.size - 1;
setSelectedSuggestion(newSelectedSuggestion);
event.preventDefault();
event.stopImmediatePropagation();
}
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_DOWN_COMMAND,
(payload) => {
const event = payload;
if (suggestions !== null && suggestions.size && selectedSuggestion !== null) {
const newSelectedSuggestion = selectedSuggestion !== suggestions.size - 1 ? selectedSuggestion + 1 : 0;
setSelectedSuggestion(newSelectedSuggestion);
event.preventDefault();
event.stopImmediatePropagation();
}
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<KeyboardEvent>(
KEY_TAB_COMMAND,
(payload) => {
const event = payload;
event.preventDefault();
event.stopImmediatePropagation();
onSelectSuggestion(selectedSuggestion);
setResolution(null);
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<KeyboardEvent>(
KEY_ENTER_COMMAND,
(payload) => {
const event = payload;
event.preventDefault();
event.stopImmediatePropagation();
onSelectSuggestion(selectedSuggestion);
setResolution(null);
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<KeyboardEvent>(
KEY_ESCAPE_COMMAND,
(payload) => {
const event = payload;
event.preventDefault();
event.stopImmediatePropagation();
setResolution(null);
return true;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, suggestions, selectedSuggestion, resolution]);
return resolution === null || editor === null ? null : (
<LexicalPopoverMenu
anchorElementRef={anchorElementRef}
menuRenderFn={(anchorElementRef) =>
anchorElementRef.current
? ReactDOM.createPortal(
<div
className={clsx({
'mt-6 fixed z-1000 shadow bg-white dark:bg-gray-900 rounded-lg py-1 space-y-0 dark:ring-2 dark:ring-primary-700 focus:outline-none': true,
hidden: suggestionsHidden || suggestions.isEmpty(),
block: !suggestionsHidden && !suggestions.isEmpty(),
})}
>
{suggestions.map(renderSuggestion)}
</div>,
anchorElementRef.current,
)
: null
}
/>
);
};
export default AutosuggestPlugin;

View File

@ -0,0 +1,38 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { COMMAND_PRIORITY_NORMAL, createCommand, type LexicalCommand } from 'lexical';
import { useEffect } from 'react';
interface IFocusPlugin {
autoFocus?: boolean
}
export const FOCUS_EDITOR_COMMAND: LexicalCommand<void> = createCommand();
const FocusPlugin: React.FC<IFocusPlugin> = ({ autoFocus }) => {
const [editor] = useLexicalComposerContext();
const focus = () => {
editor.dispatchCommand(FOCUS_EDITOR_COMMAND, undefined);
};
useEffect(() => editor.registerCommand(FOCUS_EDITOR_COMMAND, () => {
editor.focus(
() => {
const activeElement = document.activeElement;
const rootElement = editor.getRootElement();
if (rootElement !== null && (activeElement === null || !rootElement.contains(activeElement))) {
rootElement.focus({ preventScroll: true });
}
}, { defaultSelection: 'rootEnd' },
);
return true;
}, COMMAND_PRIORITY_NORMAL));
useEffect(() => {
if (autoFocus) focus();
}, []);
return null;
};
export default FocusPlugin;

View File

@ -0,0 +1,16 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { LinkPlugin as LexicalLinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import * as React from 'react';
import { validateUrl } from '../utils/url';
const LinkPlugin = (): JSX.Element => {
return <LexicalLinkPlugin validateUrl={validateUrl} />;
};
export default LinkPlugin;

View File

@ -0,0 +1,60 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity';
import { useCallback, useEffect } from 'react';
import { $createMentionNode, MentionNode } from '../nodes/mention-node';
import type { TextNode } from 'lexical';
const MENTION_REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i');
const getMentionMatch = (text: string) => {
const matchArr = MENTION_REGEX.exec(text);
if (!matchArr) return null;
return matchArr;
};
const MentionPlugin = (): JSX.Element | null => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.hasNodes([MentionNode])) {
throw new Error('MentionPlugin: MentionNode not registered on editor');
}
}, [editor]);
const createMentionNode = useCallback((textNode: TextNode): MentionNode => {
return $createMentionNode(textNode.getTextContent());
}, []);
const getEntityMatch = useCallback((text: string) => {
const matchArr = getMentionMatch(text);
if (!matchArr) return null;
const mentionLength = matchArr[3].length + 1;
const startOffset = matchArr.index + matchArr[1].length;
const endOffset = startOffset + mentionLength;
return {
end: endOffset,
start: startOffset,
};
}, []);
useLexicalTextEntity<MentionNode>(
getEntityMatch,
MentionNode,
createMentionNode,
);
return null;
};
export default MentionPlugin;

View File

@ -0,0 +1,18 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { LexicalEditor } from 'lexical';
import React, { useEffect } from 'react';
/** Set the ref to the current Lexical editor instance. */
const RefPlugin = React.forwardRef<LexicalEditor>((_props, ref) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (ref && typeof ref !== 'function') {
ref.current = editor;
}
}, [editor]);
return null;
});
export default RefPlugin;

View File

@ -0,0 +1,35 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot, KEY_ENTER_COMMAND } from 'lexical';
import { useEffect } from 'react';
import { setEditorState } from 'soapbox/actions/compose';
import { useAppDispatch } from 'soapbox/hooks';
interface IStatePlugin {
composeId: string
handleSubmit?: () => void
}
const StatePlugin = ({ composeId, handleSubmit }: IStatePlugin) => {
const dispatch = useAppDispatch();
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (handleSubmit) editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
if (event?.ctrlKey) {
handleSubmit();
return true;
}
return false;
}, 1);
editor.registerUpdateListener(({ editorState }) => {
const isEmpty = editorState.read(() => $getRoot().getTextContent()) === '';
const data = isEmpty ? null : JSON.stringify(editorState.toJSON());
dispatch(setEditorState(composeId, data));
});
}, [editor]);
return null;
};
export default StatePlugin;

View File

@ -0,0 +1,28 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
/* eslint-disable eqeqeq */
export const getDOMRangeRect = (
nativeSelection: Selection,
rootElement: HTMLElement,
): DOMRect => {
const domRange = nativeSelection.getRangeAt(0);
let rect;
if (nativeSelection.anchorNode === rootElement) {
let inner = rootElement;
while (inner.firstElementChild != null) {
inner = inner.firstElementChild as HTMLElement;
}
rect = inner.getBoundingClientRect();
} else {
rect = domRange.getBoundingClientRect();
}
return rect;
};

View File

@ -0,0 +1,26 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { $isAtNodeEnd } from '@lexical/selection';
import { ElementNode, RangeSelection, TextNode } from 'lexical';
export const getSelectedNode = (
selection: RangeSelection,
): TextNode | ElementNode => {
const anchor = selection.anchor;
const focus = selection.focus;
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
if (anchorNode === focusNode) {
return anchorNode;
}
const isBackward = selection.isBackward();
if (isBackward) {
return $isAtNodeEnd(focus) ? anchorNode : focusNode;
} else {
return $isAtNodeEnd(anchor) ? focusNode : anchorNode;
}
};

View File

@ -0,0 +1,4 @@
const isHTMLElement = (x: unknown): x is HTMLElement => x instanceof HTMLElement;
export default isHTMLElement;
export { isHTMLElement };

View File

@ -0,0 +1,57 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
class Point {
private readonly _x: number;
private readonly _y: number;
constructor(x: number, y: number) {
this._x = x;
this._y = y;
}
get x(): number {
return this._x;
}
get y(): number {
return this._y;
}
public equals({ x, y }: Point): boolean {
return this.x === x && this.y === y;
}
public calcDeltaXTo({ x }: Point): number {
return this.x - x;
}
public calcDeltaYTo({ y }: Point): number {
return this.y - y;
}
public calcHorizontalDistanceTo(point: Point): number {
return Math.abs(this.calcDeltaXTo(point));
}
public calcVerticalDistance(point: Point): number {
return Math.abs(this.calcDeltaYTo(point));
}
public calcDistanceTo(point: Point): number {
return Math.sqrt(
Math.pow(this.calcDeltaXTo(point), 2) +
Math.pow(this.calcDeltaYTo(point), 2),
);
}
}
const isPoint = (x: unknown): x is Point => x instanceof Point;
export default Point;
export { Point, isPoint };

View File

@ -0,0 +1,163 @@
/* eslint-disable no-dupe-class-members */
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { isPoint, Point } from './point';
type ContainsPointReturn = {
result: boolean
reason: {
isOnTopSide: boolean
isOnBottomSide: boolean
isOnLeftSide: boolean
isOnRightSide: boolean
}
};
class Rect {
private readonly _left: number;
private readonly _top: number;
private readonly _right: number;
private readonly _bottom: number;
constructor(left: number, top: number, right: number, bottom: number) {
const [physicTop, physicBottom] =
top <= bottom ? [top, bottom] : [bottom, top];
const [physicLeft, physicRight] =
left <= right ? [left, right] : [right, left];
this._top = physicTop;
this._right = physicRight;
this._left = physicLeft;
this._bottom = physicBottom;
}
get top(): number {
return this._top;
}
get right(): number {
return this._right;
}
get bottom(): number {
return this._bottom;
}
get left(): number {
return this._left;
}
get width(): number {
return Math.abs(this._left - this._right);
}
get height(): number {
return Math.abs(this._bottom - this._top);
}
public equals({ top, left, bottom, right }: Rect): boolean {
return (
top === this._top &&
bottom === this._bottom &&
left === this._left &&
right === this._right
);
}
public contains({ x, y }: Point): ContainsPointReturn;
public contains({ top, left, bottom, right }: Rect): boolean;
public contains(target: Point | Rect): boolean | ContainsPointReturn {
if (isPoint(target)) {
const { x, y } = target;
const isOnTopSide = y < this._top;
const isOnBottomSide = y > this._bottom;
const isOnLeftSide = x < this._left;
const isOnRightSide = x > this._right;
const result =
!isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide;
return {
reason: {
isOnBottomSide,
isOnLeftSide,
isOnRightSide,
isOnTopSide,
},
result,
};
} else {
const { top, left, bottom, right } = target;
return (
top >= this._top &&
top <= this._bottom &&
bottom >= this._top &&
bottom <= this._bottom &&
left >= this._left &&
left <= this._right &&
right >= this._left &&
right <= this._right
);
}
}
public intersectsWith(rect: Rect): boolean {
const { left: x1, top: y1, width: w1, height: h1 } = rect;
const { left: x2, top: y2, width: w2, height: h2 } = this;
const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2;
const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2;
const minX = x1 <= x2 ? x1 : x2;
const minY = y1 <= y2 ? y1 : y2;
return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2;
}
public generateNewRect({
left = this.left,
top = this.top,
right = this.right,
bottom = this.bottom,
}): Rect {
return new Rect(left, top, right, bottom);
}
static fromLTRB(
left: number,
top: number,
right: number,
bottom: number,
): Rect {
return new Rect(left, top, right, bottom);
}
static fromLWTH(
left: number,
width: number,
top: number,
height: number,
): Rect {
return new Rect(left, top, left + width, top + height);
}
static fromPoints(startPoint: Point, endPoint: Point): Rect {
const { y: top, x: left } = startPoint;
const { y: bottom, x: right } = endPoint;
return Rect.fromLTRB(left, top, right, bottom);
}
static fromDOM(dom: HTMLElement): Rect {
const { top, width, left, height } = dom.getBoundingClientRect();
return Rect.fromLWTH(left, width, top, height);
}
}
export default Rect;
export { Rect };

View File

@ -0,0 +1,45 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
const VERTICAL_GAP = 10;
const HORIZONTAL_OFFSET = 5;
export const setFloatingElemPosition = (
targetRect: ClientRect | null,
floatingElem: HTMLElement,
anchorElem: HTMLElement,
verticalGap: number = VERTICAL_GAP,
horizontalOffset: number = HORIZONTAL_OFFSET,
): void => {
const scrollerElem = anchorElem.parentElement;
if (targetRect === null || !scrollerElem) {
floatingElem.style.opacity = '0';
floatingElem.style.transform = 'translate(-10000px, -10000px)';
return;
}
const floatingElemRect = floatingElem.getBoundingClientRect();
const anchorElementRect = anchorElem.getBoundingClientRect();
const editorScrollerRect = scrollerElem.getBoundingClientRect();
let top = targetRect.top - floatingElemRect.height - verticalGap;
let left = targetRect.left - horizontalOffset;
if (top < editorScrollerRect.top) {
top += floatingElemRect.height + targetRect.height + verticalGap * 2;
}
if (left + floatingElemRect.width > editorScrollerRect.right) {
left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset;
}
top -= anchorElementRect.top;
left -= anchorElementRect.left;
floatingElem.style.opacity = '1';
floatingElem.style.transform = `translate(${left}px, ${top}px)`;
};

View File

@ -0,0 +1,32 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
export const sanitizeUrl = (url: string): string => {
/** A pattern that matches safe URLs. */
const SAFE_URL_PATTERN =
/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi;
/** A pattern that matches safe data URLs. */
const DATA_URL_PATTERN =
/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i;
url = String(url).trim();
if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url;
return 'https://';
};
// Source: https://stackoverflow.com/a/8234912/2013580
const urlRegExp = new RegExp(
/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/,
);
export const validateUrl = (url: string): boolean => {
// TODO Fix UI for link insertion; it should never default to an invalid URL such as https://.
// Maybe show a dialog where they user can type the URL before inserting it.
return url === 'https://' || urlRegExp.test(url);
};

View File

@ -98,7 +98,7 @@ const SettingsStore: React.FC = () => {
</FormActions>
</Form>
<CardHeader>
<CardHeader className='mb-4'>
<CardTitle title={intl.formatMessage(messages.advanced)} />
</CardHeader>

View File

@ -75,5 +75,4 @@ const EmojiPickerDropdownContainer = (
);
};
export default EmojiPickerDropdownContainer;

View File

@ -1,11 +1,6 @@
import data, { EmojiData } from './data';
const stripLeadingZeros = /^0+/;
function replaceAll(str: string, find: string, replace: string) {
return str.replace(new RegExp(find, 'g'), replace);
}
interface UnicodeMap {
[s: string]: {
unified: string
@ -80,7 +75,7 @@ const stripcodes = (unified: string, native: string) => {
if (unified.includes('200d') && !(unified in blacklist)) {
return stripped;
} else {
return replaceAll(stripped, '-fe0f', '');
return stripped.replaceAll('-fe0f', '');
}
};

View File

@ -109,7 +109,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
};
const handleCopy = () => {
const { uri } = status;
const { uri } = status;
copy(uri);
};

View File

@ -11,7 +11,6 @@ const messages = defineMessages({
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
interface IIconPickerMenu {
icons: Record<string, Array<string>>
onClose: () => void

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import {
@ -21,11 +21,11 @@ import { closeModal, openModal } from 'soapbox/actions/modals';
import { ADDRESS_ICONS } from 'soapbox/components/autosuggest-location';
import LocationSearch from 'soapbox/components/location-search';
import { checkEventComposeContent } from 'soapbox/components/modal-root';
import { Button, Form, FormGroup, HStack, Icon, IconButton, Input, Modal, Spinner, Stack, Tabs, Text, Textarea, Toggle } from 'soapbox/components/ui';
import { Button, Form, FormGroup, HStack, Icon, IconButton, Input, Modal, Spinner, Stack, Tabs, Text, Toggle } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { isCurrentOrFutureDate } from 'soapbox/features/compose/components/schedule-form';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import { DatePicker } from 'soapbox/features/ui/util/async-components';
import { ComposeEditor, DatePicker } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import UploadButton from './upload-button';
@ -94,13 +94,14 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const editorStateRef = useRef<string>(null);
const [tab, setTab] = useState<'edit' | 'pending'>('edit');
const banner = useAppSelector((state) => state.compose_event.banner);
const isUploading = useAppSelector((state) => state.compose_event.is_uploading);
const name = useAppSelector((state) => state.compose_event.name);
const description = useAppSelector((state) => state.compose_event.status);
const startTime = useAppSelector((state) => state.compose_event.start_time);
const endTime = useAppSelector((state) => state.compose_event.end_time);
const approvalRequired = useAppSelector((state) => state.compose_event.approval_required);
@ -114,10 +115,6 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
dispatch(changeEditEventName(target.value));
};
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
dispatch(changeEditEventDescription(target.value));
};
const onChangeStartTime = (date: Date) => {
dispatch(changeEditEventStartTime(date));
};
@ -170,6 +167,7 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
};
const handleSubmit = () => {
dispatch(changeEditEventDescription(editorStateRef.current!));
dispatch(submitEvent());
};
@ -236,14 +234,19 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
</FormGroup>
<FormGroup
labelText={<FormattedMessage id='compose_event.fields.description_label' defaultMessage='Event description' />}
hintText={<FormattedMessage id='compose_event.fields.description_hint' defaultMessage='Markdown syntax is supported' />}
>
<Textarea
autoComplete='off'
placeholder={intl.formatMessage(messages.eventDescriptionPlaceholder)}
value={description}
onChange={onChangeDescription}
/>
<BundleContainer fetchComponent={ComposeEditor}>
{(Component: any) => (
<Component
ref={editorStateRef}
className='block w-full rounded-md border border-gray-400 bg-white px-3 py-2 text-base text-gray-900 ring-1 placeholder:text-gray-600 focus-within:border-primary-500 focus-within:ring-primary-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:ring-gray-800 dark:placeholder:text-gray-600 dark:focus-within:border-primary-500 dark:focus-within:ring-primary-500 sm:text-sm'
placeholderClassName='pt-2'
composeId='compose-event-modal'
placeholder={intl.formatMessage(messages.eventDescriptionPlaceholder)}
handleSubmit={handleSubmit}
/>
)}
</BundleContainer>
</FormGroup>
<FormGroup
labelText={<FormattedMessage id='compose_event.fields.location_label' defaultMessage='Event location' />}

View File

@ -86,6 +86,7 @@ const ComposeModal: React.FC<IComposeModal> = ({ onClose, composeId = 'compose-m
<ComposeForm
id={composeId}
extra={<ComposeFormGroupToggle composeId={composeId} groupId={groupId} />}
autoFocus
/>
</Modal>
);

View File

@ -27,8 +27,8 @@ const messages = defineMessages({
reportContext: { id: 'report.chatMessage.context', defaultMessage: 'When reporting a users message, the five messages before and five messages after the one selected will be passed along to our moderation team for context.' },
reportMessage: { id: 'report.chatMessage.title', defaultMessage: 'Report message' },
reportGroup: { id: 'report.group.title', defaultMessage: 'Report Group' },
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
previous: { id: 'report.previous', defaultMessage: 'Previous' },
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
previous: { id: 'report.previous', defaultMessage: 'Previous' },
});
enum Steps {

View File

@ -641,3 +641,7 @@ export function FollowedTags() {
export function AccountNotePanel() {
return import('../components/panels/account-note-panel');
}
export function ComposeEditor() {
return import('../../compose/editor');
}

View File

@ -3,10 +3,13 @@ import { useHistory } from 'react-router-dom';
import { resetCompose } from 'soapbox/actions/compose';
import { openModal } from 'soapbox/actions/modals';
import { FOCUS_EDITOR_COMMAND } from 'soapbox/features/compose/editor/plugins/focus-plugin';
import { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { HotKeys } from '../components/hotkeys';
import type { LexicalEditor } from 'lexical';
const keyMap = {
help: '?',
new: 'n',
@ -51,10 +54,10 @@ const GlobalHotkeys: React.FC<IGlobalHotkeys> = ({ children, node }) => {
const handleHotkeyNew = (e?: KeyboardEvent) => {
e?.preventDefault();
const element = node.current?.querySelector('textarea#compose-textarea') as HTMLTextAreaElement;
const element = node.current?.querySelector('div[data-lexical-editor="true"]') as HTMLTextAreaElement;
if (element) {
element.focus();
((element as any).__lexicalEditor as LexicalEditor).dispatchCommand(FOCUS_EDITOR_COMMAND, undefined);
} else {
dispatch(openModal('COMPOSE'));
}
@ -91,7 +94,7 @@ const GlobalHotkeys: React.FC<IGlobalHotkeys> = ({ children, node }) => {
// @ts-ignore
hotkeys.current.__mousetrap__.stopCallback = (_e, element) => {
return ['TEXTAREA', 'SELECT', 'INPUT', 'EM-EMOJI-PICKER'].includes(element.tagName);
return ['TEXTAREA', 'SELECT', 'INPUT', 'EM-EMOJI-PICKER'].includes(element.tagName) || !!element.closest('[contenteditable]');
};
};

View File

@ -394,7 +394,6 @@
"compose_event.edit_success": "Your event was edited",
"compose_event.fields.approval_required": "I want to approve participation requests manually",
"compose_event.fields.banner_label": "Event banner",
"compose_event.fields.description_hint": "Markdown syntax is supported",
"compose_event.fields.description_label": "Event description",
"compose_event.fields.description_placeholder": "Description",
"compose_event.fields.end_time_label": "Event end date",
@ -418,6 +417,14 @@
"compose_form.direct_message_warning": "This post will only be sent to the mentioned users.",
"compose_form.event_placeholder": "Post to this event",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
"compose_form.lexical.create_horizontal_line": "Create horizontal line",
"compose_form.lexical.format_bold": "Format bold",
"compose_form.lexical.format_italic": "Format italic",
"compose_form.lexical.format_strikethrough": "Format strikethrough",
"compose_form.lexical.format_underline": "Format underline",
"compose_form.lexical.insert_code_block": "Insert code block",
"compose_form.lexical.insert_link": "Insert link",
"compose_form.lexical.upload_media": "Upload media",
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
"compose_form.lock_disclaimer.lock": "locked",
"compose_form.markdown.marked": "Post markdown enabled",

View File

@ -140,7 +140,6 @@ const normalizeLocked = (group: ImmutableMap<string, any>) => {
return group.set('locked', locked);
};
/** Rewrite `<p></p>` to empty string. */
const fixNote = (group: ImmutableMap<string, any>) => {
if (group.get('note') === '<p></p>') {

View File

@ -374,7 +374,7 @@ describe('compose reducer', () => {
type: actions.COMPOSE_SUGGESTION_TAGS_UPDATE,
id: 'home',
token: 'aaadken3',
currentTrends: ImmutableList([
tags: ImmutableList([
TagRecord({ name: 'hashtag' }),
]),
};

View File

@ -51,9 +51,11 @@ import {
COMPOSE_REMOVE_FROM_MENTIONS,
COMPOSE_SET_STATUS,
COMPOSE_EVENT_REPLY,
COMPOSE_EDITOR_STATE_SET,
COMPOSE_SET_GROUP_TIMELINE_VISIBLE,
ComposeAction,
} from '../actions/compose';
import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events';
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS, MeAction } from '../actions/me';
import { SETTING_CHANGE, FE_NAME, SettingsAction } from '../actions/settings';
import { TIMELINE_DELETE, TimelineAction } from '../actions/timelines';
@ -80,6 +82,7 @@ const PollRecord = ImmutableRecord({
export const ReducerCompose = ImmutableRecord({
caretPosition: null as number | null,
content_type: 'text/plain',
editorState: null as string | null,
focusDate: null as Date | null,
group_id: null as string | null,
idempotencyKey: '',
@ -180,11 +183,11 @@ const insertSuggestion = (compose: Compose, position: number, token: string | nu
});
};
const updateSuggestionTags = (compose: Compose, token: string, currentTrends: ImmutableList<Tag>) => {
const updateSuggestionTags = (compose: Compose, token: string, tags: ImmutableList<Tag>) => {
const prefix = token.slice(1);
return compose.merge({
suggestions: ImmutableList(currentTrends
suggestions: ImmutableList(tags
.filter((tag) => tag.get('name').toLowerCase().startsWith(prefix.toLowerCase()))
.slice(0, 4)
.map((tag) => '#' + tag.name)),
@ -273,7 +276,7 @@ export const initialState: State = ImmutableMap({
default: ReducerCompose({ idempotencyKey: uuid(), resetFileKey: getResetFileKey() }),
});
export default function compose(state = initialState, action: ComposeAction | MeAction | SettingsAction | TimelineAction) {
export default function compose(state = initialState, action: ComposeAction | EventsAction | MeAction | SettingsAction | TimelineAction) {
switch (action.type) {
case COMPOSE_TYPE_CHANGE:
return updateCompose(state, action.id, compose => compose.withMutations(map => {
@ -409,7 +412,7 @@ export default function compose(state = initialState, action: ComposeAction | Me
case COMPOSE_SUGGESTION_SELECT:
return updateCompose(state, action.id, compose => insertSuggestion(compose, action.position, action.token, action.completion, action.path));
case COMPOSE_SUGGESTION_TAGS_UPDATE:
return updateCompose(state, action.id, compose => updateSuggestionTags(compose, action.token, action.currentTrends));
return updateCompose(state, action.id, compose => updateSuggestionTags(compose, action.token, action.tags));
case COMPOSE_TAG_HISTORY_UPDATE:
return updateCompose(state, action.id, compose => compose.set('tagHistory', ImmutableList(fromJS(action.tags)) as ImmutableList<string>));
case TIMELINE_DELETE:
@ -511,6 +514,12 @@ export default function compose(state = initialState, action: ComposeAction | Me
return updateCompose(state, 'default', compose => importAccount(compose, action.me));
case SETTING_CHANGE:
return updateCompose(state, 'default', compose => updateSetting(compose, action.path, action.value));
case COMPOSE_EDITOR_STATE_SET:
return updateCompose(state, action.id, compose => compose.set('editorState', action.editorState as string));
case EVENT_COMPOSE_CANCEL:
return updateCompose(state, 'event-compose-modal', compose => compose.set('text', ''));
case EVENT_FORM_SET:
return updateCompose(state, 'event-compose-modal', compose => compose.set('text', action.text));
default:
return state;
}

View File

@ -33,4 +33,4 @@ const settingsSchema = z.object({
type Settings = z.infer<typeof settingsSchema>;
export { settingsSchema, type Settings };
export { settingsSchema, type Settings };

View File

@ -212,7 +212,7 @@ const findBestClient = (clients: readonly WindowClient[]): WindowClient => {
return focusedClient || visibleClient || clients[0];
};
/** Update a notification with CW to display the full status. */
/** Update a notification with CW to display the full status. */
const expandNotification = (notification: Notification) => {
const newNotification = cloneNotification(notification);

View File

@ -50,7 +50,7 @@
overflow: hidden;
&__actions {
@apply bg-gradient-to-b from-gray-900/80 via-gray-900/50 to-transparent flex items-start justify-between opacity-0 transition-opacity duration-100 ease-linear;
@apply p-2 bg-gradient-to-b from-gray-900/80 via-gray-900/50 to-transparent flex items-start gap-2 justify-end opacity-0 transition-opacity duration-100 ease-linear;
&.active {
@apply opacity-100;

View File

@ -120,3 +120,7 @@
30% { opacity: 0.75; }
100% { opacity: 1; }
}
.underline-line-through {
text-decoration: underline line-through;
}

View File

@ -24,7 +24,6 @@ const getScopes = (state: RootState) => {
return getInstanceScopes(state.instance);
};
export {
getInstanceScopes,
getScopes,

View File

@ -32,4 +32,4 @@ const textAtCursorMatchesToken = (
}
};
export { textAtCursorMatchesToken };
export { textAtCursorMatchesToken };

166
yarn.lock
View File

@ -1699,6 +1699,160 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@lexical/clipboard@0.11.3", "@lexical/clipboard@^0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/clipboard/-/clipboard-0.11.3.tgz#f4bfac8fad1f41d43a45c4e1ffc79542757314f9"
integrity sha512-6xggT8b0hd4OQy25mBH+yiJsr3Bm8APHjDOd3yINCGeiiHXIC+2qKQn3MG70euxQQuyzq++tYHcSsFq42g8Jyw==
dependencies:
"@lexical/html" "0.11.3"
"@lexical/list" "0.11.3"
"@lexical/selection" "0.11.3"
"@lexical/utils" "0.11.3"
"@lexical/code@0.11.3", "@lexical/code@^0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/code/-/code-0.11.3.tgz#4a5ef193655557859c63dd4a54012c78580585fa"
integrity sha512-BIMPd2op65iP4N9SkKIUVodZoWeSsnk6skNJ8UHBO/Rg0ZxyAqxLpnBhEgHq2QOoTBbEW6OEFtkc7/+f9LINZg==
dependencies:
"@lexical/utils" "0.11.3"
prismjs "^1.27.0"
"@lexical/dragon@0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/dragon/-/dragon-0.11.3.tgz#b4254953b09a68c20277ba0fb125548dd6630921"
integrity sha512-S18uwqOOpV2yIAFVWqSvBdhZ5BGadPQO4ejZF15wP8LUuqkxCs+0I/MjLovQ7tx0Cx34KdDaOXtM6XeG74ixYw==
"@lexical/hashtag@0.11.3", "@lexical/hashtag@^0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/hashtag/-/hashtag-0.11.3.tgz#9ffb2f7b2bb9a62fa9641355fc06feb9d209273a"
integrity sha512-7auoaWp2QhsX9/Bq0SxLXatUaSwqoT9HlWNTH2vKsw8tdeUBYacTHLuBNncTGrznXLG0/B5+FWoLuM6Pzqq4Ig==
dependencies:
"@lexical/utils" "0.11.3"
"@lexical/history@0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/history/-/history-0.11.3.tgz#bcf7b2e708a5b3b5f90d1a39e07e0ae51bac41df"
integrity sha512-QLJQRH2rbadRwXd4c/U4TqjLWDQna6Q43nCocIZF+SdVG9TlASp7m6dS7hiHfPtV1pkxJUxPhZY6EsB/Ok5WGA==
dependencies:
"@lexical/utils" "0.11.3"
"@lexical/html@0.11.3", "@lexical/html@^0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/html/-/html-0.11.3.tgz#c02b38f512eb808726922c8215dd2374df6b77cc"
integrity sha512-+8AYnxxml9PneZLkGfdTenqDjE2yD1ZfCmQLrD/L1TEn22OjZh4uvKVHb13wEhgUZTuLKF0PNdnuecko9ON/aQ==
dependencies:
"@lexical/selection" "0.11.3"
"@lexical/link@0.11.3", "@lexical/link@^0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/link/-/link-0.11.3.tgz#7efab94e3b061a84639314eaec0b915d3457329c"
integrity sha512-stAjIrDrF18dPKK25ExPwMCcMe0KKD0FWVzo3F7ejh9DvrQcLFeBPcs8ze71chS3D5fQDB/CzdwvMjEViKmq2A==
dependencies:
"@lexical/utils" "0.11.3"
"@lexical/list@0.11.3", "@lexical/list@^0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/list/-/list-0.11.3.tgz#d158ac4b4b42d772b30a1cd2a42ca0462a5a154e"
integrity sha512-Cs9071wDfqi4j1VgodceiR1jTHj13eCoEJDhr3e/FW0x5we7vfbTMtWlOWbveIoryAh+rQNgiD5e8SrAm6Zs3g==
dependencies:
"@lexical/utils" "0.11.3"
"@lexical/mark@0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/mark/-/mark-0.11.3.tgz#7f87a264d44762e275ba7b3d85e18307bbabd9e3"
integrity sha512-0wAtufmaA0rMVFXoiJ0sY/tiJsQbHuDpgywb1Qa8qnZZcg7ZTrQMz9Go0fEWYcbSp8OH2o0cjbDTz3ACS1qCUA==
dependencies:
"@lexical/utils" "0.11.3"
"@lexical/markdown@0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/markdown/-/markdown-0.11.3.tgz#0656a33bdf8b506899c010bb44960f86276e346b"
integrity sha512-sF8ow32BDme3UvxaKpf+j+vMc4T/XvDEzteZHmvvP7NX/iUtK3yUkTyT7rKuGwiKLYfMBwQaKMGjU3/nlIOzUg==
dependencies:
"@lexical/code" "0.11.3"
"@lexical/link" "0.11.3"
"@lexical/list" "0.11.3"
"@lexical/rich-text" "0.11.3"
"@lexical/text" "0.11.3"
"@lexical/utils" "0.11.3"
"@lexical/offset@0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/offset/-/offset-0.11.3.tgz#55c2ef5f036235d70f2aa762ed295e32dde9593a"
integrity sha512-3H9X8iqDSk0LrMOHZuqYuqX4EYGb78TIhtjrFbLJi/OgKmHaSeLx59xcMZdgd5kBdRitzQYMmvbRDvbLfMgWrA==
"@lexical/overflow@0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/overflow/-/overflow-0.11.3.tgz#a77d72a4fdf8dcc4f558e68a5d0ac210f020472b"
integrity sha512-ShjCG8lICShOBKwrpP+9PjRFKEBCSUUMjbIGZfLnoL//3hyRtGv5aRgRyfJlRgDhCve0ROt5znLJV88EXzGRyA==
"@lexical/plain-text@0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/plain-text/-/plain-text-0.11.3.tgz#ee56c0f0bc10a6333dc9790fb6f27b8d41887da8"
integrity sha512-cQ5Us+GNzShyjjgRqWTnYv0rC+jHJ96LvBA1aSieM77H8/Im5BeoLl6TgBK2NqPkp8fGpj8JnDEdT8h9Qh1jtA==
"@lexical/react@^0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/react/-/react-0.11.3.tgz#e889170cf29bf71e3c4799a104d4f4c99499fa2e"
integrity sha512-Rn0Agnrz3uLIWbNyS9PRlkxOxcIDl2kxaVfgBacqQtYKR0ZVB2Hnoi89Cq6VmWPovauPyryx4Q3FC8Y11X7Otg==
dependencies:
"@lexical/clipboard" "0.11.3"
"@lexical/code" "0.11.3"
"@lexical/dragon" "0.11.3"
"@lexical/hashtag" "0.11.3"
"@lexical/history" "0.11.3"
"@lexical/link" "0.11.3"
"@lexical/list" "0.11.3"
"@lexical/mark" "0.11.3"
"@lexical/markdown" "0.11.3"
"@lexical/overflow" "0.11.3"
"@lexical/plain-text" "0.11.3"
"@lexical/rich-text" "0.11.3"
"@lexical/selection" "0.11.3"
"@lexical/table" "0.11.3"
"@lexical/text" "0.11.3"
"@lexical/utils" "0.11.3"
"@lexical/yjs" "0.11.3"
react-error-boundary "^3.1.4"
"@lexical/rich-text@0.11.3", "@lexical/rich-text@^0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/rich-text/-/rich-text-0.11.3.tgz#9501789bfe9671c220da7e95bf8589fa92cecf8c"
integrity sha512-fBFs6wMS7GFLbk+mzIWtwpP+EmnTZZ5bHpveuQ5wXONBuUuLcsYF5KO7UhLxXNLmiViV6lxatZPavEzgZdW7oQ==
"@lexical/selection@0.11.3", "@lexical/selection@^0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/selection/-/selection-0.11.3.tgz#f7250fae305a84c6e264a413f5feab056aeabfa3"
integrity sha512-15lQpcKT/vd7XZ5pnF1nb+kpKb72e9Yi1dVqieSxTeXkzt1cAZFKP3NB4RlhOKCv1N+glSBnjSxRwgsFfbD+NQ==
"@lexical/table@0.11.3", "@lexical/table@^0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/table/-/table-0.11.3.tgz#9b1980d828d7a588aaffa4cb8c6bb61401729034"
integrity sha512-EyRnN39CSPsMceADBR7Kf+sBHNpNQlPEkn/52epeDSnakR6s80woyrA3kIzKo6mLB4afvoqdYc7RfR96M9JLIA==
dependencies:
"@lexical/utils" "0.11.3"
"@lexical/text@0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/text/-/text-0.11.3.tgz#81ce2b5cd0caa9d89372e52c3548173d9395ad3d"
integrity sha512-gCEN8lJyR6b+yaOwKWGj79pbOfCQPWU/PHWyoNFUkEJXn3KydCzr2EYb6ta2cvQWRQU4G2BClKCR56jL4NS+qg==
"@lexical/utils@0.11.3", "@lexical/utils@^0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/utils/-/utils-0.11.3.tgz#c4eb953289d29943974008c75f80c8d4e294e009"
integrity sha512-vC4saCrlcmyIJnvrYKw1uYxZojlD1DCIBsFlgmO8kXyRYXjj+o/8PBdn2dsgSQ3rADrC2mUloOm/maekDcYe9Q==
dependencies:
"@lexical/list" "0.11.3"
"@lexical/selection" "0.11.3"
"@lexical/table" "0.11.3"
"@lexical/yjs@0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@lexical/yjs/-/yjs-0.11.3.tgz#b7049c0be85945fe0766b614256df8121bb05499"
integrity sha512-TLDQG2FSEw/aOfppEBb0wRlIuzJ57W//8ImfzyZvckSC12tvU0YKQQX8nQz/rybXdyfRy5eN+8gX5K2EyZx+pQ==
dependencies:
"@lexical/offset" "0.11.3"
"@mdn/browser-compat-data@^5.2.34", "@mdn/browser-compat-data@^5.3.13":
version "5.3.16"
resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-5.3.16.tgz#c3b6585c256461fe5e2eac85182b11b36ea2678b"
@ -5830,6 +5984,11 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
lexical@^0.11.3:
version "0.11.3"
resolved "https://registry.yarnpkg.com/lexical/-/lexical-0.11.3.tgz#1ad1a56a657eb55d1b9644733f271bf75a65cbe9"
integrity sha512-xsMKgx/Fa+QHg/nweemU04lCy7TnEr8LyeDtsKUC7fIDN9wH3GqbnQ0+e3Hbg4FmxlhDCiPPt0GcZAROq3R8uw==
li@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/li/-/li-1.3.0.tgz#22c59bcaefaa9a8ef359cf759784e4bf106aea1b"
@ -6993,6 +7152,11 @@ pretty-format@^29.5.0:
ansi-styles "^5.0.0"
react-is "^18.0.0"
prismjs@^1.27.0:
version "1.29.0"
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12"
integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==
process@^0.11.10:
version "0.11.10"
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
@ -7127,7 +7291,7 @@ react-dom@^18.0.0:
loose-envify "^1.1.0"
scheduler "^0.23.0"
react-error-boundary@^3.1.0:
react-error-boundary@^3.1.0, react-error-boundary@^3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==