Merge branch 'drafts' into fork

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2024-04-28 15:27:01 +02:00
22 changed files with 472 additions and 24 deletions

View File

@@ -1,7 +1,7 @@
import clsx from 'clsx';
import { CLEAR_EDITOR_COMMAND, TextNode, type LexicalEditor, $getRoot } from 'lexical';
import { CLEAR_EDITOR_COMMAND, TextNode, type LexicalEditor } from 'lexical';
import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { length } from 'stringz';
@@ -86,6 +86,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
is_uploading: isUploading,
schedule: scheduledAt,
group_id: groupId,
text,
} = compose;
const prevSpoiler = usePrevious(spoiler);
@@ -102,7 +103,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const editorRef = useRef<LexicalEditor>(null);
const { isDraggedOver } = useDraggedFiles(formRef);
const text = editorRef.current?.getEditorState().read(() => $getRoot().getTextContent()) ?? '';
const fulltext = [spoilerText, countableText(text)].join('');
const isEmpty = !(fulltext.trim() || anyMedia);

View File

@@ -15,9 +15,10 @@ const StatePlugin: React.FC<IStatePlugin> = ({ composeId }) => {
useEffect(() => {
editor.registerUpdateListener(({ editorState }) => {
const isEmpty = editorState.read(() => $getRoot().getTextContent()) === '';
const text = editorState.read(() => $getRoot().getTextContent());
const isEmpty = text === '';
const data = isEmpty ? null : JSON.stringify(editorState.toJSON());
dispatch(setEditorState(composeId, data));
dispatch(setEditorState(composeId, data, text));
});
}, [editor]);

View File

@@ -0,0 +1,43 @@
import { Map as ImmutableMap } from 'immutable';
import { Entities } from 'soapbox/entity-store/entities';
import { normalizeStatus } from 'soapbox/normalizers/status';
import { calculateStatus } from 'soapbox/reducers/statuses';
import type { DraftStatus } from 'soapbox/reducers/draft-statuses';
import type { RootState } from 'soapbox/store';
const buildPoll = (draftStatus: DraftStatus) => {
if (draftStatus.hasIn(['poll', 'options'])) {
return draftStatus.poll!
.set('id', `${draftStatus.draft_id}-poll`)
.update('options', (options: ImmutableMap<string, any>) => {
return options.map((title: string) => ImmutableMap({ title }));
});
} else {
return null;
}
};
export const buildStatus = (state: RootState, draftStatus: DraftStatus) => {
const me = state.me as string;
const account = state.entities[Entities.ACCOUNTS]?.store[me];
const status = ImmutableMap({
account,
content: draftStatus.text.replace(new RegExp('\n', 'g'), '<br>'), /* eslint-disable-line no-control-regex */
created_at: draftStatus.schedule,
group: draftStatus.group_id,
in_reply_to_id: draftStatus.in_reply_to,
media_attachments: draftStatus.media_attachments,
poll: buildPoll(draftStatus),
quote: draftStatus.quote,
sensitive: draftStatus.sensitive,
spoiler_text: draftStatus.spoiler_text,
uri: `/draft_statuses/${draftStatus.draft_id}`,
url: `/draft_statuses/${draftStatus.draft_id}`,
visibility: draftStatus.privacy,
});
return calculateStatus(normalizeStatus(status));
};

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { setComposeToStatus } from 'soapbox/actions/compose';
import { cancelDraftStatus } from 'soapbox/actions/draft-statuses';
import { openModal } from 'soapbox/actions/modals';
import { getSettings } from 'soapbox/actions/settings';
import { Button, HStack } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import type { DraftStatus } from 'soapbox/reducers/draft-statuses';
import type { Status as StatusEntity } from 'soapbox/types/entities';
const messages = defineMessages({
edit: { id: 'draft_status.edit', defaultMessage: 'Edit' },
cancel: { id: 'draft_status.cancel', defaultMessage: 'Cancel' },
deleteConfirm: { id: 'confirmations.draft_status_delete.confirm', defaultMessage: 'Discard' },
deleteHeading: { id: 'confirmations.draft_status_delete.heading', defaultMessage: 'Cancel draft post' },
deleteMessage: { id: 'confirmations.draft_status_delete.message', defaultMessage: 'Are you sure you want to discard this draft post?' },
});
interface IDraftStatusActionBar {
source: DraftStatus;
status: StatusEntity;
}
const DraftStatusActionBar: React.FC<IDraftStatusActionBar> = ({ source, status }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleCancelClick = () => {
dispatch((_, getState) => {
const deleteModal = getSettings(getState()).get('deleteModal');
if (!deleteModal) {
dispatch(cancelDraftStatus(source.draft_id));
} else {
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/outline/calendar-stats.svg'),
heading: intl.formatMessage(messages.deleteHeading),
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(cancelDraftStatus(source.draft_id)),
}));
}
});
};
const handleEditClick = () => {
dispatch(setComposeToStatus(status, source.text, source.spoiler_text, source.content_type, false, source.draft_id, source.editorState));
dispatch(openModal('COMPOSE'));
};
return (
<HStack space={2} justifyContent='end'>
<Button theme='primary' size='sm' onClick={handleEditClick}>
<FormattedMessage id='draft_status.edit' defaultMessage='Edit' />
</Button>
<Button theme='danger' size='sm' onClick={handleCancelClick}>
<FormattedMessage id='draft_status.cancel' defaultMessage='Delete' />
</Button>
</HStack>
);
};
export default DraftStatusActionBar;

View File

@@ -0,0 +1,88 @@
import clsx from 'clsx';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Account from 'soapbox/components/account';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import StatusContent from 'soapbox/components/status-content';
import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
import { HStack, Stack } from 'soapbox/components/ui';
import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container';
import PollPreview from 'soapbox/features/ui/components/poll-preview';
import { useAppSelector } from 'soapbox/hooks';
import { buildStatus } from '../builder';
import DraftStatusActionBar from './draft-status-action-bar';
import type { DraftStatus as DraftStatusType } from 'soapbox/reducers/draft-statuses';
import type { Poll as PollEntity, Status as StatusEntity } from 'soapbox/types/entities';
interface IDraftStatus {
draftStatus: DraftStatusType;
}
const DraftStatus: React.FC<IDraftStatus> = ({ draftStatus, ...other }) => {
const status = useAppSelector((state) => {
if (!draftStatus) return null;
return buildStatus(state, draftStatus);
}) as StatusEntity | null;
if (!status) return null;
const account = status.account;
let quote;
if (status.quote) {
if (status.pleroma.get('quote_visible', true) === false) {
quote = (
<div className='quoted-status-tombstone'>
<p><FormattedMessage id='statuses.quote_tombstone' defaultMessage='Post is unavailable.' /></p>
</div>
);
} else {
quote = <QuotedStatus statusId={status.quote as string} />;
}
}
return (
<div className={clsx('status__wrapper py-4', `status__wrapper-${status.visibility}`, { 'status__wrapper-reply': !!status.in_reply_to_id })} tabIndex={0}>
<div className={clsx('status', `status-${status.visibility}`, { 'status-reply': !!status.in_reply_to_id })} data-id={status.id}>
<div className='mb-4'>
<HStack justifyContent='between' alignItems='start'>
<Account
key={account.id}
account={account}
timestamp={status.created_at}
futureTimestamp
action={<DraftStatusActionBar source={draftStatus} status={status} {...other} />}
/>
</HStack>
</div>
<StatusReplyMentions status={status} />
<Stack space={4}>
<StatusContent
status={status}
collapsable
/>
{status.media_attachments.size > 0 && (
<AttachmentThumbs
media={status.media_attachments}
sensitive={status.sensitive}
/>
)}
{quote}
{status.poll && <PollPreview poll={status.poll as PollEntity} />}
</Stack>
</div>
</div>
);
};
export default DraftStatus;

View File

@@ -0,0 +1,40 @@
import React, { useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchDraftStatuses } from 'soapbox/actions/draft-statuses';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Column } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import DraftStatus from './components/draft-status';
const messages = defineMessages({
heading: { id: 'column.draft_statuses', defaultMessage: 'Drafts' },
});
const DraftStatuses = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const drafts = useAppSelector((state) => state.draft_statuses);
useEffect(() => {
dispatch(fetchDraftStatuses());
}, []);
const emptyMessage = <FormattedMessage id='empty_column.draft_statuses' defaultMessage="You don't have any draft statuses yet. When you add one, it will show up here." />;
return (
<Column label={intl.formatMessage(messages.heading)}>
<ScrollableList
scrollKey='draft_statuses'
emptyMessage={emptyMessage}
listClassName='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
>
{drafts.toOrderedSet().reverse().map((draft) => <DraftStatus key={draft.draft_id} draftStatus={draft} />)}
</ScrollableList>
</Column>
);
};
export default DraftStatuses;

View File

@@ -13,7 +13,7 @@ import { buildStatus } from '../builder';
import ScheduledStatusActionBar from './scheduled-status-action-bar';
import type { Status as StatusEntity } from 'soapbox/types/entities';
import type { Poll as PollEntity, Status as StatusEntity } from 'soapbox/types/entities';
interface IScheduledStatus {
statusId: string;
@@ -31,7 +31,7 @@ const ScheduledStatus: React.FC<IScheduledStatus> = ({ statusId, ...other }) =>
const account = status.account;
return (
<div className={clsx('status__wrapper', `status__wrapper-${status.visibility}`, { 'status__wrapper-reply': !!status.in_reply_to_id })} tabIndex={0}>
<div className={clsx('status__wrapper py-4', `status__wrapper-${status.visibility}`, { 'status__wrapper-reply': !!status.in_reply_to_id })} tabIndex={0}>
<div className={clsx('status', `status-${status.visibility}`, { 'status-reply': !!status.in_reply_to_id })} data-id={status.id}>
<div className='mb-4'>
<HStack justifyContent='between' alignItems='start'>
@@ -60,7 +60,7 @@ const ScheduledStatus: React.FC<IScheduledStatus> = ({ statusId, ...other }) =>
/>
)}
{status.poll && <PollPreview pollId={status.poll as string} />}
{status.poll && <PollPreview poll={status.poll as PollEntity} />}
</Stack>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import React, { useRef } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { cancelReplyCompose, uploadCompose } from 'soapbox/actions/compose';
import { saveDraftStatus } from 'soapbox/actions/draft-statuses';
import { openModal, closeModal } from 'soapbox/actions/modals';
import { checkComposeContent } from 'soapbox/components/modal-root';
import { Modal } from 'soapbox/components/ui';
@@ -13,6 +14,7 @@ import ComposeForm from '../../../compose/components/compose-form';
const messages = defineMessages({
confirm: { id: 'confirmations.cancel.confirm', defaultMessage: 'Discard' },
cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' },
saveDraft: { id: 'confirmations.cancel_editing.save_draft', defaultMessage: 'Save draft' },
});
interface IComposeModal {
@@ -47,6 +49,12 @@ const ComposeModal: React.FC<IComposeModal> = ({ onClose, composeId = 'compose-m
dispatch(closeModal('COMPOSE'));
dispatch(cancelReplyCompose());
},
secondary: intl.formatMessage(messages.saveDraft),
onSecondary: statusId ? undefined : () => {
dispatch(saveDraftStatus(composeId));
dispatch(closeModal('COMPOSE'));
dispatch(cancelReplyCompose());
},
}));
} else {
onClose('COMPOSE');

View File

@@ -14,7 +14,7 @@ import { buildStatus } from '../util/pending-status-builder';
import PollPreview from './poll-preview';
import type { Status as StatusEntity } from 'soapbox/types/entities';
import type { Poll as PollEntity, Status as StatusEntity } from 'soapbox/types/entities';
const shouldHaveCard = (pendingStatus: StatusEntity) => {
return Boolean(pendingStatus.content.match(/https?:\/\/\S*/));
@@ -89,7 +89,7 @@ const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, mu
<PendingStatusMedia status={status} />
{status.poll && <PollPreview pollId={status.poll as string} />}
{status.poll && <PollPreview poll={status.poll as PollEntity} />}
{status.quote && <QuotedStatus statusId={status.quote as string} />}
</Stack>

View File

@@ -3,17 +3,14 @@ import React from 'react';
import PollOption from 'soapbox/components/polls/poll-option';
import { Stack } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { Poll as PollEntity } from 'soapbox/types/entities';
interface IPollPreview {
pollId: string;
poll: PollEntity;
}
const PollPreview: React.FC<IPollPreview> = ({ pollId }) => {
const poll = useAppSelector((state) => state.polls.get(pollId) as PollEntity);
if (!poll) {
const PollPreview: React.FC<IPollPreview> = ({ poll }) => {
if (typeof poll !== 'object') {
return null;
}

View File

@@ -5,6 +5,7 @@ import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
import { fetchFollowRequests } from 'soapbox/actions/accounts';
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis';
import { fetchDraftStatuses } from 'soapbox/actions/draft-statuses';
import { fetchFilters } from 'soapbox/actions/filters';
import { fetchMarker } from 'soapbox/actions/markers';
import { expandNotifications } from 'soapbox/actions/notifications';
@@ -131,6 +132,7 @@ import {
Bech32Redirect,
Relays,
Rules,
DraftStatuses,
} from './util/async-components';
import GlobalHotkeys from './util/global-hotkeys';
import { WrappedRoute } from './util/react-router-helpers';
@@ -289,6 +291,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
<WrappedRoute path='/statuses/:statusId' exact page={StatusPage} component={Status} content={children} />
{features.scheduledStatuses && <WrappedRoute path='/scheduled_statuses' page={DefaultPage} component={ScheduledStatuses} content={children} />}
<WrappedRoute path='/draft_statuses' page={DefaultPage} component={DraftStatuses} content={children} />
<WrappedRoute path='/settings/profile' page={DefaultPage} component={EditProfile} content={children} />
{features.nip05 && <WrappedRoute path='/settings/identity' page={DefaultPage} component={EditIdentity} content={children} />}
@@ -387,6 +390,8 @@ const UI: React.FC<IUI> = ({ children }) => {
const loadAccountData = () => {
if (!account) return;
dispatch(fetchDraftStatuses());
dispatch(expandHomeTimeline({}, () => {
dispatch(fetchSuggestionsForTimeline());
}));

View File

@@ -164,3 +164,4 @@ export const Bech32Redirect = lazy(() => import('soapbox/features/nostr/Bech32Re
export const Relays = lazy(() => import('soapbox/features/admin/relays'));
export const Rules = lazy(() => import('soapbox/features/admin/rules'));
export const EditRuleModal = lazy(() => import('soapbox/features/ui/components/modals/edit-rule-modal'));
export const DraftStatuses = lazy(() => import('soapbox/features/draft-statuses'));