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: [ plugins: [
'react',
'jsdoc', 'jsdoc',
'react',
'jsx-a11y', 'jsx-a11y',
'import', 'import',
'promise', 'promise',

View File

@ -50,6 +50,17 @@
"@fontsource/inter": "^5.0.0", "@fontsource/inter": "^5.0.0",
"@fontsource/roboto-mono": "^5.0.0", "@fontsource/roboto-mono": "^5.0.0",
"@gamestdio/websocket": "^0.3.2", "@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", "@popperjs/core": "^2.11.5",
"@reach/combobox": "^0.18.0", "@reach/combobox": "^0.18.0",
"@reach/menu-button": "^0.18.0", "@reach/menu-button": "^0.18.0",
@ -112,6 +123,7 @@
"intl-messageformat-parser": "^6.0.0", "intl-messageformat-parser": "^6.0.0",
"intl-pluralrules": "^2.0.0", "intl-pluralrules": "^2.0.0",
"leaflet": "^1.8.0", "leaflet": "^1.8.0",
"lexical": "^0.11.3",
"line-awesome": "^1.3.0", "line-awesome": "^1.3.0",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash": "^4.7.11", "lodash": "^4.7.11",

View File

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

View File

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

View File

@ -6,21 +6,21 @@ import { defineMessages, IntlShape } from 'react-intl';
import api from 'soapbox/api'; import api from 'soapbox/api';
import { isNativeEmoji } from 'soapbox/features/emoji'; import { isNativeEmoji } from 'soapbox/features/emoji';
import emojiSearch from 'soapbox/features/emoji/search'; import emojiSearch from 'soapbox/features/emoji/search';
import { normalizeTag } from 'soapbox/normalizers';
import { selectAccount, selectOwnAccount } from 'soapbox/selectors'; import { selectAccount, selectOwnAccount } from 'soapbox/selectors';
import { tagHistory } from 'soapbox/settings'; import { tagHistory } from 'soapbox/settings';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth'; import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures, parseVersion } from 'soapbox/utils/features'; 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 { useEmoji } from './emojis';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts } from './importer';
import { uploadMedia, fetchMedia, updateMedia } from './media'; import { uploadFile, updateMedia } from './media';
import { openModal, closeModal } from './modals'; import { openModal, closeModal } from './modals';
import { getSettings } from './settings'; import { getSettings } from './settings';
import { createStatus } from './statuses'; import { createStatus } from './statuses';
import type { EditorState } from 'lexical';
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import type { Emoji } from 'soapbox/features/emoji'; import type { Emoji } from 'soapbox/features/emoji';
import type { Account, Group } from 'soapbox/schemas'; import type { Account, Group } from 'soapbox/schemas';
@ -30,7 +30,7 @@ import type { History } from 'soapbox/types/history';
const { CancelToken, isCancel } = axios; const { CancelToken, isCancel } = axios;
let cancelFetchComposeSuggestionsAccounts: Canceler; let cancelFetchComposeSuggestions: Canceler;
const COMPOSE_CHANGE = 'COMPOSE_CHANGE' as const; const COMPOSE_CHANGE = 'COMPOSE_CHANGE' as const;
const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' 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_SET_STATUS = 'COMPOSE_SET_STATUS' as const;
const COMPOSE_EDITOR_STATE_SET = 'COMPOSE_EDITOR_STATE_SET' as const;
const messages = defineMessages({ 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.' }, 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' }, success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' },
editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' }, 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) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;
const state = getState(); const state = getState();
const { richText } = getFeatures(state.instance);
const compose = state.compose.get(composeId)!; const compose = state.compose.get(composeId)!;
@ -312,6 +312,8 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
const statusId = compose.id; const statusId = compose.id;
let to = compose.to; let to = compose.to;
const contentType = richText ? 'text/markdown' : 'text/plain';
if (!validateSchedule(state, composeId)) { if (!validateSchedule(state, composeId)) {
toast.error(messages.scheduleError); toast.error(messages.scheduleError);
return; return;
@ -350,7 +352,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
sensitive: compose.sensitive, sensitive: compose.sensitive,
spoiler_text: compose.spoiler_text, spoiler_text: compose.spoiler_text,
visibility: compose.privacy, visibility: compose.privacy,
content_type: compose.content_type, content_type: contentType,
poll: compose.poll, poll: compose.poll,
scheduled_at: compose.schedule, scheduled_at: compose.schedule,
to, to,
@ -392,9 +394,6 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;
const attachmentLimit = getState().instance.configuration.getIn(['statuses', 'max_media_attachments']) as number; 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 media = getState().compose.get(composeId)?.media_attachments;
const progress = new Array(files.length).fill(0); 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) => { Array.from(files).forEach(async(f, i) => {
if (mediaCount + i > attachmentLimit - 1) return; if (mediaCount + i > attachmentLimit - 1) return;
const isImage = f.type.match(/image.*/); dispatch(uploadFile(
const isVideo = f.type.match(/video.*/); f,
const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(f) : 0; intl,
(data) => dispatch(uploadComposeSuccess(composeId, data, f)),
if (isImage && maxImageSize && (f.size > maxImageSize)) { (error) => dispatch(uploadComposeFail(composeId, error)),
const limit = formatBytes(maxImageSize); ({ loaded }: any) => {
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) => {
progress[i] = loaded; progress[i] = loaded;
dispatch(uploadComposeProgress(composeId, progress.reduce((a, v) => a + v, 0), total)); 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>) => const changeUploadCompose = (composeId: string, id: string, params: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;
@ -507,34 +488,6 @@ const changeUploadComposeFail = (composeId: string, id: string, error: AxiosErro
skipLoading: true, 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) => ({ const undoUploadCompose = (composeId: string, media_id: string) => ({
type: COMPOSE_UPLOAD_UNDO, type: COMPOSE_UPLOAD_UNDO,
id: composeId, id: composeId,
@ -554,8 +507,8 @@ const setGroupTimelineVisible = (composeId: string, groupTimelineVisible: boolea
}); });
const clearComposeSuggestions = (composeId: string) => { const clearComposeSuggestions = (composeId: string) => {
if (cancelFetchComposeSuggestionsAccounts) { if (cancelFetchComposeSuggestions) {
cancelFetchComposeSuggestionsAccounts(); cancelFetchComposeSuggestions();
} }
return { return {
type: COMPOSE_SUGGESTIONS_CLEAR, type: COMPOSE_SUGGESTIONS_CLEAR,
@ -564,12 +517,12 @@ const clearComposeSuggestions = (composeId: string) => {
}; };
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => { const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => {
if (cancelFetchComposeSuggestionsAccounts) { if (cancelFetchComposeSuggestions) {
cancelFetchComposeSuggestionsAccounts(composeId); cancelFetchComposeSuggestions(composeId);
} }
api(getState).get('/api/v1/accounts/search', { api(getState).get('/api/v1/accounts/search', {
cancelToken: new CancelToken(cancel => { cancelToken: new CancelToken(cancel => {
cancelFetchComposeSuggestionsAccounts = cancel; cancelFetchComposeSuggestions = cancel;
}), }),
params: { params: {
q: token.slice(1), 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 fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
const state = getState(); if (cancelFetchComposeSuggestions) {
const currentTrends = state.trends.items; 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) => const fetchComposeSuggestions = (composeId: string, token: string) =>
@ -675,11 +655,11 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str
dispatch(action); 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, type: COMPOSE_SUGGESTION_TAGS_UPDATE,
id: composeId, id: composeId,
token, token,
currentTrends, tags,
}); });
const updateTagHistory = (composeId: string, tags: string[]) => ({ 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 = type ComposeAction =
ComposeSetStatusAction ComposeSetStatusAction
| ReturnType<typeof changeCompose> | ReturnType<typeof changeCompose>
@ -906,6 +892,7 @@ type ComposeAction =
| ComposeAddToMentionsAction | ComposeAddToMentionsAction
| ComposeRemoveFromMentionsAction | ComposeRemoveFromMentionsAction
| ComposeEventReplyAction | ComposeEventReplyAction
| ReturnType<typeof setEditorState>
export { export {
COMPOSE_CHANGE, COMPOSE_CHANGE,
@ -952,6 +939,7 @@ export {
COMPOSE_ADD_TO_MENTIONS, COMPOSE_ADD_TO_MENTIONS,
COMPOSE_REMOVE_FROM_MENTIONS, COMPOSE_REMOVE_FROM_MENTIONS,
COMPOSE_SET_STATUS, COMPOSE_SET_STATUS,
COMPOSE_EDITOR_STATE_SET,
COMPOSE_SET_GROUP_TIMELINE_VISIBLE, COMPOSE_SET_GROUP_TIMELINE_VISIBLE,
setComposeToStatus, setComposeToStatus,
changeCompose, changeCompose,
@ -968,6 +956,7 @@ export {
submitComposeRequest, submitComposeRequest,
submitComposeSuccess, submitComposeSuccess,
submitComposeFail, submitComposeFail,
uploadFile,
uploadCompose, uploadCompose,
changeUploadCompose, changeUploadCompose,
changeUploadComposeRequest, changeUploadComposeRequest,
@ -1006,5 +995,6 @@ export {
addToMentions, addToMentions,
removeFromMentions, removeFromMentions,
eventDiscussionCompose, eventDiscussionCompose,
setEditorState,
type ComposeAction, type ComposeAction,
}; };

View File

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

View File

@ -2,11 +2,9 @@ import { defineMessages, IntlShape } from 'react-intl';
import api, { getLinks } from 'soapbox/api'; import api, { getLinks } from 'soapbox/api';
import toast from 'soapbox/toast'; 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 { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer';
import { fetchMedia, uploadMedia } from './media'; import { uploadFile } from './media';
import { closeModal, openModal } from './modals'; import { closeModal, openModal } from './modals';
import { import {
STATUS_FETCH_SOURCE_FAIL, STATUS_FETCH_SOURCE_FAIL,
@ -15,73 +13,74 @@ import {
} from './statuses'; } from './statuses';
import type { AxiosError } from 'axios'; import type { AxiosError } from 'axios';
import type { ReducerStatus } from 'soapbox/reducers/statuses';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities'; import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities';
const LOCATION_SEARCH_REQUEST = 'LOCATION_SEARCH_REQUEST'; const LOCATION_SEARCH_REQUEST = 'LOCATION_SEARCH_REQUEST' as const;
const LOCATION_SEARCH_SUCCESS = 'LOCATION_SEARCH_SUCCESS'; const LOCATION_SEARCH_SUCCESS = 'LOCATION_SEARCH_SUCCESS' as const;
const LOCATION_SEARCH_FAIL = 'LOCATION_SEARCH_FAIL'; const LOCATION_SEARCH_FAIL = 'LOCATION_SEARCH_FAIL' as const;
const EDIT_EVENT_NAME_CHANGE = 'EDIT_EVENT_NAME_CHANGE'; const EDIT_EVENT_NAME_CHANGE = 'EDIT_EVENT_NAME_CHANGE' as const;
const EDIT_EVENT_DESCRIPTION_CHANGE = 'EDIT_EVENT_DESCRIPTION_CHANGE'; const EDIT_EVENT_DESCRIPTION_CHANGE = 'EDIT_EVENT_DESCRIPTION_CHANGE' as const;
const EDIT_EVENT_START_TIME_CHANGE = 'EDIT_EVENT_START_TIME_CHANGE'; 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'; 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'; const EDIT_EVENT_END_TIME_CHANGE = 'EDIT_EVENT_END_TIME_CHANGE' as const;
const EDIT_EVENT_APPROVAL_REQUIRED_CHANGE = 'EDIT_EVENT_APPROVAL_REQUIRED_CHANGE'; const EDIT_EVENT_APPROVAL_REQUIRED_CHANGE = 'EDIT_EVENT_APPROVAL_REQUIRED_CHANGE' as const;
const EDIT_EVENT_LOCATION_CHANGE = 'EDIT_EVENT_LOCATION_CHANGE'; const EDIT_EVENT_LOCATION_CHANGE = 'EDIT_EVENT_LOCATION_CHANGE' as const;
const EVENT_BANNER_UPLOAD_REQUEST = 'EVENT_BANNER_UPLOAD_REQUEST'; const EVENT_BANNER_UPLOAD_REQUEST = 'EVENT_BANNER_UPLOAD_REQUEST' as const;
const EVENT_BANNER_UPLOAD_PROGRESS = 'EVENT_BANNER_UPLOAD_PROGRESS'; const EVENT_BANNER_UPLOAD_PROGRESS = 'EVENT_BANNER_UPLOAD_PROGRESS' as const;
const EVENT_BANNER_UPLOAD_SUCCESS = 'EVENT_BANNER_UPLOAD_SUCCESS'; const EVENT_BANNER_UPLOAD_SUCCESS = 'EVENT_BANNER_UPLOAD_SUCCESS' as const;
const EVENT_BANNER_UPLOAD_FAIL = 'EVENT_BANNER_UPLOAD_FAIL'; const EVENT_BANNER_UPLOAD_FAIL = 'EVENT_BANNER_UPLOAD_FAIL' as const;
const EVENT_BANNER_UPLOAD_UNDO = 'EVENT_BANNER_UPLOAD_UNDO'; const EVENT_BANNER_UPLOAD_UNDO = 'EVENT_BANNER_UPLOAD_UNDO' as const;
const EVENT_SUBMIT_REQUEST = 'EVENT_SUBMIT_REQUEST'; const EVENT_SUBMIT_REQUEST = 'EVENT_SUBMIT_REQUEST' as const;
const EVENT_SUBMIT_SUCCESS = 'EVENT_SUBMIT_SUCCESS'; const EVENT_SUBMIT_SUCCESS = 'EVENT_SUBMIT_SUCCESS' as const;
const EVENT_SUBMIT_FAIL = 'EVENT_SUBMIT_FAIL'; const EVENT_SUBMIT_FAIL = 'EVENT_SUBMIT_FAIL' as const;
const EVENT_JOIN_REQUEST = 'EVENT_JOIN_REQUEST'; const EVENT_JOIN_REQUEST = 'EVENT_JOIN_REQUEST' as const;
const EVENT_JOIN_SUCCESS = 'EVENT_JOIN_SUCCESS'; const EVENT_JOIN_SUCCESS = 'EVENT_JOIN_SUCCESS' as const;
const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL'; const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL' as const;
const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST'; const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST' as const;
const EVENT_LEAVE_SUCCESS = 'EVENT_LEAVE_SUCCESS'; const EVENT_LEAVE_SUCCESS = 'EVENT_LEAVE_SUCCESS' as const;
const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL'; const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL' as const;
const EVENT_PARTICIPATIONS_FETCH_REQUEST = 'EVENT_PARTICIPATIONS_FETCH_REQUEST'; const EVENT_PARTICIPATIONS_FETCH_REQUEST = 'EVENT_PARTICIPATIONS_FETCH_REQUEST' as const;
const EVENT_PARTICIPATIONS_FETCH_SUCCESS = 'EVENT_PARTICIPATIONS_FETCH_SUCCESS'; const EVENT_PARTICIPATIONS_FETCH_SUCCESS = 'EVENT_PARTICIPATIONS_FETCH_SUCCESS' as const;
const EVENT_PARTICIPATIONS_FETCH_FAIL = 'EVENT_PARTICIPATIONS_FETCH_FAIL'; const EVENT_PARTICIPATIONS_FETCH_FAIL = 'EVENT_PARTICIPATIONS_FETCH_FAIL' as const;
const EVENT_PARTICIPATIONS_EXPAND_REQUEST = 'EVENT_PARTICIPATIONS_EXPAND_REQUEST'; const EVENT_PARTICIPATIONS_EXPAND_REQUEST = 'EVENT_PARTICIPATIONS_EXPAND_REQUEST' as const;
const EVENT_PARTICIPATIONS_EXPAND_SUCCESS = 'EVENT_PARTICIPATIONS_EXPAND_SUCCESS'; const EVENT_PARTICIPATIONS_EXPAND_SUCCESS = 'EVENT_PARTICIPATIONS_EXPAND_SUCCESS' as const;
const EVENT_PARTICIPATIONS_EXPAND_FAIL = 'EVENT_PARTICIPATIONS_EXPAND_FAIL'; 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_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST' as const;
const EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS'; const EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS' as const;
const EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL = 'EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL'; 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_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST' as const;
const EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS'; const EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS' as const;
const EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL'; 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_REQUEST = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST' as const;
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS'; const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS' as const;
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL'; 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_REQUEST = 'EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST' as const;
const EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS'; const EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS' as const;
const EVENT_PARTICIPATION_REQUEST_REJECT_FAIL = 'EVENT_PARTICIPATION_REQUEST_REJECT_FAIL'; 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_REQUEST = 'RECENT_EVENTS_FETCH_REQUEST' as const;
const RECENT_EVENTS_FETCH_SUCCESS = 'RECENT_EVENTS_FETCH_SUCCESS'; const RECENT_EVENTS_FETCH_SUCCESS = 'RECENT_EVENTS_FETCH_SUCCESS' as const;
const RECENT_EVENTS_FETCH_FAIL = 'RECENT_EVENTS_FETCH_FAIL'; const RECENT_EVENTS_FETCH_FAIL = 'RECENT_EVENTS_FETCH_FAIL' as const;
const JOINED_EVENTS_FETCH_REQUEST = 'JOINED_EVENTS_FETCH_REQUEST'; const JOINED_EVENTS_FETCH_REQUEST = 'JOINED_EVENTS_FETCH_REQUEST' as const;
const JOINED_EVENTS_FETCH_SUCCESS = 'JOINED_EVENTS_FETCH_SUCCESS'; const JOINED_EVENTS_FETCH_SUCCESS = 'JOINED_EVENTS_FETCH_SUCCESS' as const;
const JOINED_EVENTS_FETCH_FAIL = 'JOINED_EVENTS_FETCH_FAIL'; const JOINED_EVENTS_FETCH_FAIL = 'JOINED_EVENTS_FETCH_FAIL' as const;
const noOp = () => new Promise(f => f(undefined)); const noOp = () => new Promise(f => f(undefined));
@ -153,52 +152,21 @@ const changeEditEventLocation = (value: string | null) =>
}; };
const uploadEventBanner = (file: File, intl: IntlShape) => const uploadEventBanner = (file: File, intl: IntlShape) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch) => {
const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined;
let progress = 0; let progress = 0;
dispatch(uploadEventBannerRequest()); dispatch(uploadEventBannerRequest());
if (maxImageSize && (file.size > maxImageSize)) { dispatch(uploadFile(
const limit = formatBytes(maxImageSize); file,
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); intl,
toast.error(message); (data) => dispatch(uploadEventBannerSuccess(data, file)),
dispatch(uploadEventBannerFail(true)); (error) => dispatch(uploadEventBannerFail(error)),
return; ({ loaded }: any) => {
}
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) => {
progress = loaded; progress = loaded;
dispatch(uploadEventBannerProgress(progress)); 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 = () => ({ const uploadEventBannerRequest = () => ({
@ -576,6 +544,13 @@ const cancelEventCompose = () => ({
type: EVENT_COMPOSE_CANCEL, 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 editEvent = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
const status = getState().statuses.get(id)!; const status = getState().statuses.get(id)!;
@ -612,7 +587,7 @@ const fetchRecentEvents = () =>
next: next ? next.uri : null, next: next ? next.uri : null,
}); });
}).catch(error => { }).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, next: next ? next.uri : null,
}); });
}).catch(error => { }).catch(error => {
dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error }); dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error });
}); });
}; };
type EventsAction =
| ReturnType<typeof cancelEventCompose>
| EventFormSetAction;
export { export {
LOCATION_SEARCH_REQUEST, LOCATION_SEARCH_REQUEST,
LOCATION_SEARCH_SUCCESS, LOCATION_SEARCH_SUCCESS,
@ -743,4 +722,5 @@ export {
editEvent, editEvent,
fetchRecentEvents, fetchRecentEvents,
fetchJoinedEvents, 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 { getFeatures } from 'soapbox/utils/features';
import { formatBytes, getVideoDuration } from 'soapbox/utils/media';
import resizeImage from 'soapbox/utils/resize-image';
import api from '../api'; import api from '../api';
import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store'; 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) => {}; 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 { export {
fetchMedia, fetchMedia,
updateMedia, updateMedia,
uploadMediaV1, uploadMediaV1,
uploadMediaV2, uploadMediaV2,
uploadMedia, uploadMedia,
uploadFile,
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import React from 'react'; 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> */ /** Class name for the <svg> */
className?: string className?: string
/** Tooltip text for the icon. */ /** Tooltip text for the icon. */

View File

@ -84,7 +84,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
type={revealed ? 'text' : type} type={revealed ? 'text' : type}
ref={ref} ref={ref}
className={clsx('text-base placeholder:text-gray-600 dark:placeholder:text-gray-600', { 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), ['normal', 'search'].includes(theme),
'text-gray-900 dark:text-gray-100': !props.disabled, 'text-gray-900 dark:text-gray-100': !props.disabled,
'text-gray-600': props.disabled, 'text-gray-600': props.disabled,

View File

@ -15,7 +15,6 @@ const Portal: React.FC<IPortal> = ({ children }) => {
setIsRendered(true); setIsRendered(true);
}, []); }, []);
if (!isRendered) { if (!isRendered) {
return null; 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 getPointerPosition = (el: HTMLElement, event: MouseEvent & TouchEvent): Point => {
const box = findElementPosition(el); const box = findElementPosition(el);
const boxW = el.offsetWidth; 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 clsx from 'clsx';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { spring } from 'react-motion'; import { spring } from 'react-motion';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import Blurhash from 'soapbox/components/blurhash'; import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon'; 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 Motion from 'soapbox/features/ui/util/optional-motion';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import { Attachment } from 'soapbox/types/entities'; import { Attachment } from 'soapbox/types/entities';
@ -57,6 +57,7 @@ export const MIMETYPE_ICONS: Record<string, string> = {
const messages = defineMessages({ const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
delete: { id: 'upload_form.undo', defaultMessage: 'Delete' }, delete: { id: 'upload_form.undo', defaultMessage: 'Delete' },
preview: { id: 'upload_form.preview', defaultMessage: 'Preview' },
}); });
interface IUpload { interface IUpload {
@ -152,30 +153,34 @@ const Upload: React.FC<IUpload> = ({
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => ( {({ scale }) => (
<div <div
className={clsx('compose-form__upload-thumbnail', mediaType)} className={clsx('compose-form__upload-thumbnail', mediaType)}
style={{ style={{
transform: `scale(${scale})`, transform: `scale(${scale})`,
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined, backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined }} backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined }}
> >
<div className={clsx('compose-form__upload__actions', { active })}> <HStack className='absolute right-2 top-2 z-10' space={2}>
{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 */}
{(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && ( {(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && (
<IconButton <IconButton
onClick={handleOpenModal} onClick={handleOpenModal}
src={require('@tabler/icons/zoom-in.svg')} 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 && ( {onDescriptionChange && (
<div className={clsx('compose-form__upload-description', { active })}> <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(); updateQuery();
}, []); }, []);
const hasMore = items.count() < total && !!next; const hasMore = items.count() < total && !!next;
const showLoading = isLoading && items.isEmpty(); const showLoading = isLoading && items.isEmpty();

View File

@ -2,7 +2,6 @@ import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso'; import { VirtuosoMockContext } from 'react-virtuoso';
import { ChatContext } from 'soapbox/contexts/chat-context'; import { ChatContext } from 'soapbox/contexts/chat-context';
import { buildAccount } from 'soapbox/jest/factory'; import { buildAccount } from 'soapbox/jest/factory';
import { normalizeChatMessage, normalizeInstance } from 'soapbox/normalizers'; 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 { ChatContext } from 'soapbox/contexts/chat-context';
import { StatProvider } from 'soapbox/contexts/stat-context'; import { StatProvider } from 'soapbox/contexts/stat-context';
import chats from 'soapbox/jest/fixtures/chats.json'; 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'; import ChatPane from '../chat-pane';

View File

@ -1,6 +1,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { CLEAR_EDITOR_COMMAND, type LexicalEditor } from 'lexical';
import React, { useCallback, useEffect, useRef, useState } from 'react'; 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 { Link, useHistory } from 'react-router-dom';
import { length } from 'stringz'; import { length } from 'stringz';
@ -17,6 +18,8 @@ import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea'; import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
import { Button, HStack, Stack } from 'soapbox/components/ui'; import { Button, HStack, Stack } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container'; 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 { useAppDispatch, useAppSelector, useCompose, useDraggedFiles, useFeatures, useInstance, usePrevious } from 'soapbox/hooks';
import { isMobile } from 'soapbox/is-mobile'; import { isMobile } from 'soapbox/is-mobile';
@ -49,7 +52,6 @@ const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' }, placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' }, pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' },
eventPlaceholder: { id: 'compose_form.event_placeholder', defaultMessage: 'Post to this event' }, 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' }, publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
message: { id: 'compose_form.message', defaultMessage: 'Message' }, 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 compose = useCompose(id);
const showSearch = useAppSelector((state) => state.search.submitted && !state.search.hidden); 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 maxTootChars = configuration.getIn(['statuses', 'max_characters']) as number;
const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size); const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size);
const features = useFeatures(); 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 prevSpoiler = usePrevious(spoiler);
const hasPoll = !!compose.poll; 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 anyMedia = compose.media_attachments.size > 0;
const [composeFocused, setComposeFocused] = useState(false); const [composeFocused, setComposeFocused] = useState(false);
const [text, setText] = useState('');
const firstRender = useRef(true); const firstRender = useRef(true);
const formRef = useRef<HTMLDivElement>(null); const formRef = useRef<HTMLDivElement>(null);
const spoilerTextRef = useRef<AutosuggestInput>(null); const spoilerTextRef = useRef<AutosuggestInput>(null);
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null); const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
const editorRef = useRef<LexicalEditor>(null);
const { isDraggedOver } = useDraggedFiles(formRef); 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 = () => { const getClickableArea = () => {
return clickableAreaRef ? clickableAreaRef.current : formRef.current; 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>) => { const handleSubmit = (e?: React.FormEvent<Element>) => {
if (text !== autosuggestTextareaRef.current?.textarea?.value) { dispatch(changeCompose(id, text));
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
dispatch(changeCompose(id, autosuggestTextareaRef.current!.textarea!.value));
}
// Submit disabled: // Submit disabled:
const fulltext = [spoilerText, countableText(text)].join(''); const fulltext = [spoilerText, countableText(text)].join('');
@ -159,6 +160,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
} }
dispatch(submitCompose(id, history)); dispatch(submitCompose(id, history));
editorRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
}; };
const onSuggestionsClearRequested = () => { const onSuggestionsClearRequested = () => {
@ -169,10 +171,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
dispatch(fetchComposeSuggestions(id, token as string)); 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) => { const onSpoilerSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => {
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text'])); 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 disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia);
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth); const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
let publishText: string = ''; const composeModifiers = !condensed && (
let publishIcon: string | undefined; <Stack space={4} className='compose-form__modifiers'>
let textareaPlaceholder: MessageDescriptor; <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) { if (isEditing) {
publishText = intl.formatMessage(messages.saveChanges); publishText = intl.formatMessage(messages.saveChanges);
} else if (privacy === 'direct') { } else if (privacy === 'direct') {
publishText = intl.formatMessage(messages.message);
publishIcon = require('@tabler/icons/mail.svg'); publishIcon = require('@tabler/icons/mail.svg');
publishText = intl.formatMessage(messages.message);
} else if (privacy === 'private') { } else if (privacy === 'private') {
publishText = intl.formatMessage(messages.publish);
publishIcon = require('@tabler/icons/lock.svg'); publishIcon = require('@tabler/icons/lock.svg');
publishText = intl.formatMessage(messages.publish);
} else { } else {
publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); 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); publishText = intl.formatMessage(messages.schedule);
} }
if (event) {
textareaPlaceholder = messages.eventPlaceholder;
} else if (hasPoll) {
textareaPlaceholder = messages.pollPlaceholder;
} else {
textareaPlaceholder = messages.placeholder;
}
return ( return (
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}> <Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
{scheduledStatusCount > 0 && !event && !group && ( {scheduledStatusCount > 0 && !event && !group && (
@ -306,42 +312,26 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
{!shouldCondense && !event && !group && <ReplyMentions composeId={id} />} {!shouldCondense && !event && !group && <ReplyMentions composeId={id} />}
<AutosuggestTextarea <div>
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef} <Bundle fetchComponent={ComposeEditor}>
placeholder={intl.formatMessage(textareaPlaceholder)} {(Component: any) => (
disabled={disabled} <Component
value={text} ref={editorRef}
onChange={handleChange} className='mt-2'
suggestions={suggestions} composeId={id}
onKeyDown={handleKeyDown} condensed={condensed}
onFocus={handleComposeFocus} eventDiscussion={!!event}
onSuggestionsFetchRequested={onSuggestionsFetchRequested} autoFocus={shouldAutoFocus}
onSuggestionsClearRequested={onSuggestionsClearRequested} hasPoll={hasPoll}
onSuggestionSelected={onSuggestionSelected} handleSubmit={handleSubmit}
onPaste={onPaste} onChange={setText}
autoFocus={shouldAutoFocus} onFocus={handleComposeFocus}
condensed={condensed} onPaste={onPaste}
id='compose-textarea' />
> )}
{ </Bundle>
!condensed && ( {composeModifiers}
<Stack space={4} className='compose-form__modifiers'> </div>
<UploadForm composeId={id} />
<PollForm composeId={id} />
<SpoilerInput
composeId={id}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSpoilerSuggestionSelected}
ref={spoilerTextRef}
/>
<ScheduleFormContainer composeId={id} />
</Stack>
)
}
</AutosuggestTextarea>
<QuotedStatusContainer composeId={id} /> <QuotedStatusContainer composeId={id} />
@ -362,7 +352,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
</HStack> </HStack>
)} )}
<Button type='submit' theme='primary' text={publishText} icon={publishIcon} disabled={disabledButton} /> <Button type='submit' theme='primary' icon={publishIcon} text={publishText} disabled={disabledButton} />
</HStack> </HStack>
</div> </div>
</Stack> </Stack>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,6 @@ const messages = defineMessages({
const listenerOptions = supportsPassiveEvents ? { passive: true } : false; const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
interface IIconPickerMenu { interface IIconPickerMenu {
icons: Record<string, Array<string>> icons: Record<string, Array<string>>
onClose: () => void 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 { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { import {
@ -21,11 +21,11 @@ import { closeModal, openModal } from 'soapbox/actions/modals';
import { ADDRESS_ICONS } from 'soapbox/components/autosuggest-location'; import { ADDRESS_ICONS } from 'soapbox/components/autosuggest-location';
import LocationSearch from 'soapbox/components/location-search'; import LocationSearch from 'soapbox/components/location-search';
import { checkEventComposeContent } from 'soapbox/components/modal-root'; 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 AccountContainer from 'soapbox/containers/account-container';
import { isCurrentOrFutureDate } from 'soapbox/features/compose/components/schedule-form'; import { isCurrentOrFutureDate } from 'soapbox/features/compose/components/schedule-form';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container'; 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 { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import UploadButton from './upload-button'; import UploadButton from './upload-button';
@ -94,13 +94,14 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const editorStateRef = useRef<string>(null);
const [tab, setTab] = useState<'edit' | 'pending'>('edit'); const [tab, setTab] = useState<'edit' | 'pending'>('edit');
const banner = useAppSelector((state) => state.compose_event.banner); const banner = useAppSelector((state) => state.compose_event.banner);
const isUploading = useAppSelector((state) => state.compose_event.is_uploading); const isUploading = useAppSelector((state) => state.compose_event.is_uploading);
const name = useAppSelector((state) => state.compose_event.name); 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 startTime = useAppSelector((state) => state.compose_event.start_time);
const endTime = useAppSelector((state) => state.compose_event.end_time); const endTime = useAppSelector((state) => state.compose_event.end_time);
const approvalRequired = useAppSelector((state) => state.compose_event.approval_required); const approvalRequired = useAppSelector((state) => state.compose_event.approval_required);
@ -114,10 +115,6 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
dispatch(changeEditEventName(target.value)); dispatch(changeEditEventName(target.value));
}; };
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
dispatch(changeEditEventDescription(target.value));
};
const onChangeStartTime = (date: Date) => { const onChangeStartTime = (date: Date) => {
dispatch(changeEditEventStartTime(date)); dispatch(changeEditEventStartTime(date));
}; };
@ -170,6 +167,7 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
}; };
const handleSubmit = () => { const handleSubmit = () => {
dispatch(changeEditEventDescription(editorStateRef.current!));
dispatch(submitEvent()); dispatch(submitEvent());
}; };
@ -236,14 +234,19 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
</FormGroup> </FormGroup>
<FormGroup <FormGroup
labelText={<FormattedMessage id='compose_event.fields.description_label' defaultMessage='Event description' />} 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 <BundleContainer fetchComponent={ComposeEditor}>
autoComplete='off' {(Component: any) => (
placeholder={intl.formatMessage(messages.eventDescriptionPlaceholder)} <Component
value={description} ref={editorStateRef}
onChange={onChangeDescription} 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>
<FormGroup <FormGroup
labelText={<FormattedMessage id='compose_event.fields.location_label' defaultMessage='Event location' />} 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 <ComposeForm
id={composeId} id={composeId}
extra={<ComposeFormGroupToggle composeId={composeId} groupId={groupId} />} extra={<ComposeFormGroupToggle composeId={composeId} groupId={groupId} />}
autoFocus
/> />
</Modal> </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.' }, 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' }, reportMessage: { id: 'report.chatMessage.title', defaultMessage: 'Report message' },
reportGroup: { id: 'report.group.title', defaultMessage: 'Report Group' }, reportGroup: { id: 'report.group.title', defaultMessage: 'Report Group' },
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' }, cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
previous: { id: 'report.previous', defaultMessage: 'Previous' }, previous: { id: 'report.previous', defaultMessage: 'Previous' },
}); });
enum Steps { enum Steps {

View File

@ -641,3 +641,7 @@ export function FollowedTags() {
export function AccountNotePanel() { export function AccountNotePanel() {
return import('../components/panels/account-note-panel'); 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 { resetCompose } from 'soapbox/actions/compose';
import { openModal } from 'soapbox/actions/modals'; 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 { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { HotKeys } from '../components/hotkeys'; import { HotKeys } from '../components/hotkeys';
import type { LexicalEditor } from 'lexical';
const keyMap = { const keyMap = {
help: '?', help: '?',
new: 'n', new: 'n',
@ -51,10 +54,10 @@ const GlobalHotkeys: React.FC<IGlobalHotkeys> = ({ children, node }) => {
const handleHotkeyNew = (e?: KeyboardEvent) => { const handleHotkeyNew = (e?: KeyboardEvent) => {
e?.preventDefault(); 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) { if (element) {
element.focus(); ((element as any).__lexicalEditor as LexicalEditor).dispatchCommand(FOCUS_EDITOR_COMMAND, undefined);
} else { } else {
dispatch(openModal('COMPOSE')); dispatch(openModal('COMPOSE'));
} }
@ -91,7 +94,7 @@ const GlobalHotkeys: React.FC<IGlobalHotkeys> = ({ children, node }) => {
// @ts-ignore // @ts-ignore
hotkeys.current.__mousetrap__.stopCallback = (_e, element) => { 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.edit_success": "Your event was edited",
"compose_event.fields.approval_required": "I want to approve participation requests manually", "compose_event.fields.approval_required": "I want to approve participation requests manually",
"compose_event.fields.banner_label": "Event banner", "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_label": "Event description",
"compose_event.fields.description_placeholder": "Description", "compose_event.fields.description_placeholder": "Description",
"compose_event.fields.end_time_label": "Event end date", "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.direct_message_warning": "This post will only be sent to the mentioned users.",
"compose_form.event_placeholder": "Post to this event", "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.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": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
"compose_form.lock_disclaimer.lock": "locked", "compose_form.lock_disclaimer.lock": "locked",
"compose_form.markdown.marked": "Post markdown enabled", "compose_form.markdown.marked": "Post markdown enabled",

View File

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

View File

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

View File

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

View File

@ -33,4 +33,4 @@ const settingsSchema = z.object({
type Settings = z.infer<typeof settingsSchema>; 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]; 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 expandNotification = (notification: Notification) => {
const newNotification = cloneNotification(notification); const newNotification = cloneNotification(notification);

View File

@ -50,7 +50,7 @@
overflow: hidden; overflow: hidden;
&__actions { &__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 { &.active {
@apply opacity-100; @apply opacity-100;

View File

@ -120,3 +120,7 @@
30% { opacity: 0.75; } 30% { opacity: 0.75; }
100% { opacity: 1; } 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); return getInstanceScopes(state.instance);
}; };
export { export {
getInstanceScopes, getInstanceScopes,
getScopes, 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/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14" "@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": "@mdn/browser-compat-data@^5.2.34", "@mdn/browser-compat-data@^5.3.13":
version "5.3.16" version "5.3.16"
resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-5.3.16.tgz#c3b6585c256461fe5e2eac85182b11b36ea2678b" 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" prelude-ls "^1.2.1"
type-check "~0.4.0" 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: li@^1.3.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/li/-/li-1.3.0.tgz#22c59bcaefaa9a8ef359cf759784e4bf106aea1b" 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" ansi-styles "^5.0.0"
react-is "^18.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: process@^0.11.10:
version "0.11.10" version "0.11.10"
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" 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" loose-envify "^1.1.0"
scheduler "^0.23.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" version "3.1.4"
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==