Files
ncd-fe/packages/pl-fe/src/stores/compose.ts
nicole mikołajczyk 7150879186 nicolium: types
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
2026-02-27 00:02:43 +01:00

1037 lines
33 KiB
TypeScript

import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { create } from 'zustand';
import { mutative } from 'zustand-mutative';
import { uploadFile, updateMedia } from '@/actions/media';
import { saveSettings } from '@/actions/settings';
import { FE_NAME } from '@/actions/settings';
import { createStatus } from '@/actions/statuses';
import { getClient } from '@/api';
import { isNativeEmoji } from '@/features/emoji';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useClient } from '@/hooks/use-client';
import { useFeatures } from '@/hooks/use-features';
import { useInstance } from '@/hooks/use-instance';
import { selectAccount, selectOwnAccount } from '@/queries/accounts/selectors';
import { queryClient } from '@/queries/client';
import { cancelDraftStatus } from '@/queries/statuses/use-draft-statuses';
import { useModalsActions, useModalsStore } from '@/stores/modals';
import { useSettings, useSettingsStore } from '@/stores/settings';
import toast from '@/toast';
import type { AutoSuggestion } from '@/components/autosuggest-input';
import type { Language } from '@/features/preferences';
import type { NormalizedStatus as Status } from '@/reducers/statuses';
import type { AppDispatch, RootState } from '@/store';
import type { LinkOptions } from '@tanstack/react-router';
import type {
Account,
CreateStatusParams,
Group,
MediaAttachment,
Status as BaseStatus,
Poll,
InteractionPolicy,
UpdateMediaParams,
Location,
EditStatusParams,
CredentialAccount,
Instance,
StatusSource,
} from 'pl-api';
const messages = defineMessages({
scheduleError: {
id: 'compose.invalid_schedule',
defaultMessage: 'You must schedule a post at least 5 minutes out.',
},
success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent!' },
editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' },
redactSuccess: { id: 'compose.redact_success', defaultMessage: 'The post was redacted' },
scheduledSuccess: { id: 'compose.scheduled_success', defaultMessage: 'Your post was scheduled' },
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: {
id: 'upload_error.poll',
defaultMessage: 'File upload not allowed with polls.',
},
view: { id: 'toast.view', defaultMessage: 'View' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: {
id: 'confirmations.reply.message',
defaultMessage:
'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?',
},
});
const getResetFileKey = () => Math.floor(Math.random() * 0x10000);
interface ComposePoll {
options: Array<string>;
options_map: Array<Record<Language | string, string>>;
expires_in: number;
multiple: boolean;
hide_totals: boolean;
}
interface ClearLinkSuggestion {
key: string;
originalUrl: string;
cleanUrl: string;
}
interface Compose {
// User-edited text
editorState: string | null;
editorStateMap: Record<Language | string, string | null>;
spoilerText: string;
spoilerTextMap: Record<Language | string, string>;
text: string;
textMap: Record<Language | string, string>;
// Non-text content
mediaAttachments: Array<MediaAttachment>;
poll: ComposePoll | null;
location: Location | null;
// Post settings
contentType: string;
interactionPolicy: InteractionPolicy | null;
quoteApprovalPolicy: CreateStatusParams['quote_approval_policy'] | null;
language: Language | string | null;
localOnly: boolean;
scheduledAt: Date | null;
sensitive: boolean;
visibility: string;
// References to other posts/groups/users
draftId: string | null;
groupId: string | null;
editedId: string | null;
inReplyToId: string | null;
quoteId: string | null;
to: Array<string>;
parentRebloggedById: string | null;
// State flags
isChangingUpload: boolean;
isSubmitting: boolean;
isUploading: boolean;
progress: number;
// Internal
caretPosition: number | null;
idempotencyKey: string;
resetFileKey: number | null;
// Currently modified language
modifiedLanguage: Language | string | null;
// Suggestions
approvalRequired: boolean;
clearLinkSuggestion: ClearLinkSuggestion | null;
dismissedClearLinksSuggestions: Array<string>;
dismissedQuotes: Array<string>;
hashtagCasingSuggestion: string | null;
hashtagCasingSuggestionIgnored: boolean | null;
preview: Partial<BaseStatus> | null;
suggestedLanguage: string | null;
showLocationPicker: boolean;
// Moderation features
redacting: boolean;
redactingOverwrite: boolean;
}
const newCompose = (params: Partial<Compose> = {}): Compose => ({
editorState: null,
editorStateMap: {},
spoilerText: '',
spoilerTextMap: {},
text: '',
textMap: {},
mediaAttachments: [],
poll: null,
location: null,
contentType: 'text/plain',
interactionPolicy: null,
quoteApprovalPolicy: null,
language: null,
localOnly: false,
scheduledAt: null,
sensitive: false,
visibility: 'public',
draftId: null,
groupId: null,
editedId: null,
inReplyToId: null,
quoteId: null,
to: [],
parentRebloggedById: null,
isChangingUpload: false,
isSubmitting: false,
isUploading: false,
progress: 0,
caretPosition: null,
idempotencyKey: '',
resetFileKey: null,
modifiedLanguage: null,
approvalRequired: false,
clearLinkSuggestion: null,
dismissedClearLinksSuggestions: [],
dismissedQuotes: [],
hashtagCasingSuggestion: null,
hashtagCasingSuggestionIgnored: null,
preview: null,
suggestedLanguage: null,
showLocationPicker: false,
redacting: false,
redactingOverwrite: false,
...params,
});
const newPoll = (params: Partial<ComposePoll> = {}): ComposePoll => ({
options: ['', ''],
options_map: [{}, {}],
expires_in: 24 * 3600,
multiple: false,
hide_totals: false,
...params,
});
const statusToTextMentions = (
status: Pick<Status, 'account_id' | 'mentions'>,
account: Pick<Account, 'acct'>,
) => {
const statusAccount = selectAccount(status.account_id);
const author = statusAccount?.acct;
const mentions = status.mentions.map((m) => m.acct);
return [...new Set([author, ...mentions].filter((acct) => acct && acct !== account.acct))]
.map((m) => `@${m} `)
.join('');
};
const statusToMentionsArray = (
status: Pick<Status, 'account_id' | 'mentions'>,
account: Pick<Account, 'acct'>,
rebloggedBy?: Pick<Account, 'acct'>,
) => {
const statusAccount = selectAccount(status.account_id);
const author = statusAccount?.acct;
const mentions = status.mentions.map((m) => m.acct);
return [
...new Set(
[author, ...(rebloggedBy ? [rebloggedBy.acct] : []), ...mentions].filter(
(acct): acct is string => !!acct && acct !== account.acct,
),
),
];
};
const statusToMentionsAccountIdsArray = (
status: Pick<Status, 'mentions' | 'account_id'>,
account: Pick<Account, 'id'>,
parentRebloggedBy?: string | null,
) => {
const mentions = status.mentions.map((m) => m.id);
return [
...new Set(
[status.account_id, ...(parentRebloggedBy ? [parentRebloggedBy] : []), ...mentions].filter(
(id) => id !== account.id,
),
),
];
};
const privacyPreference = (
a: string,
b: string,
list_id: number | null,
conversationScope = false,
) => {
if (['private', 'subscribers'].includes(a) && conversationScope) return 'conversation';
const order = ['public', 'unlisted', 'mutuals_only', 'private', 'direct', 'local'];
if (a === 'group') return a;
if (a === 'list' && list_id !== null) return `list:${list_id}`;
return order[Math.max(order.indexOf(a), order.indexOf(b), 0)];
};
const domParser = new DOMParser();
const getExplicitMentions = (me: string, status: Pick<Status, 'content' | 'mentions'>) => {
const fragment = domParser.parseFromString(status.content, 'text/html').documentElement;
const mentions = status.mentions
.filter((mention) => !(fragment.querySelector(`a[href="${mention.url}"]`) ?? mention.id === me))
.map((m) => m.acct);
return [...new Set(mentions)];
};
const appendMedia = (compose: Compose, media: MediaAttachment) => {
const prevSize = compose.mediaAttachments.length;
compose.mediaAttachments.push(media);
compose.isUploading = false;
compose.resetFileKey = Math.floor(Math.random() * 0x10000);
if (prevSize === 0 && compose.sensitive) {
compose.sensitive = true;
}
};
interface ComposeState {
default: Compose;
composers: Record<string, Compose>;
}
interface ComposeActions {
updateCompose: (composeId: string, updater: (draft: Compose) => void) => void;
updateAllCompose: (updater: (draft: Compose) => void) => void;
getCompose: (composeId: string) => Compose;
setComposeToStatus: (
status: Pick<
Status,
| 'id'
| 'account_id'
| 'content'
| 'group_id'
| 'in_reply_to_id'
| 'language'
| 'media_attachments'
| 'mentions'
| 'quote_id'
| 'sensitive'
| 'spoiler_text'
| 'visibility'
>,
poll: Poll | null | undefined,
source: Pick<StatusSource, 'content_type' | 'text' | 'spoiler_text'>,
withRedraft?: boolean,
draftId?: string | null,
editorState?: string | null,
redacting?: boolean,
) => void;
replyCompose: (
status: Pick<
Status,
| 'id'
| 'account_id'
| 'group_id'
| 'list_id'
| 'local_only'
| 'mentions'
| 'spoiler_text'
| 'visibility'
>,
rebloggedBy?: Pick<Account, 'acct' | 'id'>,
approvalRequired?: boolean,
) => void;
quoteCompose: (
status: Pick<Status, 'id' | 'account_id' | 'visibility' | 'group_id' | 'list_id'>,
approvalRequired?: boolean,
) => void;
mentionCompose: (account: Pick<Account, 'acct'>) => void;
directCompose: (account: Pick<Account, 'acct'>) => void;
groupComposeModal: (group: Pick<Group, 'id'>) => void;
openComposeWithText: (composeId: string, text?: string) => void;
eventDiscussionCompose: (
composeId: string,
status: Pick<Status, 'id' | 'account_id' | 'mentions'>,
) => void;
resetCompose: (composeId?: string) => void;
selectComposeSuggestion: (
composeId: string,
position: number,
token: string | null,
suggestion: AutoSuggestion,
path: ['spoiler_text'] | ['poll', 'options', number],
) => void;
importDefaultSettings: (account: CredentialAccount) => void;
importDefaultContentType: (instance: Instance) => void;
handleTimelineDelete: (statusId: string) => void;
}
type ComposeStore = ComposeState & { actions: ComposeActions };
let lazyStore: { dispatch: AppDispatch; getState: () => RootState };
import('@/store').then(({ store }) => (lazyStore = store)).catch(() => {});
const useComposeStore = create<ComposeStore>()(
mutative(
(set, get) => ({
default: newCompose({ idempotencyKey: crypto.randomUUID(), resetFileKey: getResetFileKey() }),
composers: {},
actions: {
updateCompose: (composeId, updater) => {
set((state) => {
if (!state.composers[composeId]) {
state.composers[composeId] = {
...state.default,
idempotencyKey: crypto.randomUUID(),
};
}
updater(state.composers[composeId]);
});
},
updateAllCompose: (updater) => {
set((state) => {
Object.values(state.composers).forEach((compose) => {
updater(compose);
});
});
},
getCompose: (composeId) => get().composers[composeId] ?? get().default,
setComposeToStatus: (
status,
poll,
source,
withRedraft = false,
draftId = null,
editorState = null,
redacting = false,
) => {
const { features } = getClient(lazyStore.getState);
const explicitAddressing =
features.createStatusExplicitAddressing &&
!useSettingsStore.getState().settings.forceImplicitAddressing;
set((state) => {
state.composers['compose-modal'] = {
...state.default,
idempotencyKey: crypto.randomUUID(),
};
const compose = state.composers['compose-modal'];
const mentions = explicitAddressing
? getExplicitMentions(status.account_id, status)
: [];
if (!withRedraft && !draftId) {
compose.editedId = status.id;
}
compose.text = source.text;
compose.to = mentions;
compose.parentRebloggedById = null;
compose.inReplyToId = status.in_reply_to_id;
compose.visibility = status.visibility;
compose.caretPosition = null;
const contentType =
source.content_type === 'text/markdown' && state.default.contentType === 'wysiwyg'
? 'wysiwyg'
: source.content_type || 'text/plain';
compose.contentType = contentType;
compose.quoteId = status.quote_id;
compose.groupId = status.group_id;
compose.language = status.language;
compose.mediaAttachments = status.media_attachments;
compose.sensitive = status.sensitive;
compose.redacting = redacting ?? false;
compose.spoilerText = source.spoiler_text;
if (poll) {
compose.poll = newPoll({
options: poll.options.map(({ title }) => title),
multiple: poll.multiple,
expires_in: 24 * 3600,
});
}
if (draftId) {
compose.draftId = draftId;
}
if (editorState) {
compose.editorState = editorState;
}
});
},
replyCompose: (status, rebloggedBy, approvalRequired) => {
const state = lazyStore.getState();
const { features } = getClient(lazyStore.getState);
const { forceImplicitAddressing, preserveSpoilers } =
useSettingsStore.getState().settings;
const explicitAddressing =
features.createStatusExplicitAddressing && !forceImplicitAddressing;
const account = selectOwnAccount(state);
if (!account) return;
set((draft) => {
if (!draft.composers['compose-modal']) {
draft.composers['compose-modal'] = {
...draft.default,
idempotencyKey: crypto.randomUUID(),
};
}
const compose = draft.composers['compose-modal'];
const mentions = explicitAddressing
? statusToMentionsArray(status, account, rebloggedBy)
: [];
compose.groupId = status.group_id;
compose.inReplyToId = status.id;
compose.to = mentions;
compose.parentRebloggedById = rebloggedBy?.id ?? null;
compose.text = !explicitAddressing ? statusToTextMentions(status, account) : '';
compose.visibility = privacyPreference(
status.visibility,
draft.default.visibility,
status.list_id,
features.createStatusConversationScope,
);
compose.localOnly = status.local_only === true;
compose.caretPosition = null;
compose.contentType = draft.default.contentType;
compose.approvalRequired = approvalRequired ?? false;
if (preserveSpoilers && status.spoiler_text) {
compose.sensitive = true;
compose.spoilerText = status.spoiler_text;
}
});
useModalsStore.getState().actions.openModal('COMPOSE');
},
quoteCompose: (status, approvalRequired) => {
set((draft) => {
if (!draft.composers['compose-modal']) {
draft.composers['compose-modal'] = {
...draft.default,
idempotencyKey: crypto.randomUUID(),
};
}
const compose = draft.composers['compose-modal'];
const statusAccount = selectAccount(status.account_id);
const author = statusAccount?.acct ?? '';
compose.quoteId = status.id;
compose.to = [author];
compose.parentRebloggedById = null;
compose.text = '';
compose.visibility = privacyPreference(
status.visibility,
draft.default.visibility,
status.list_id,
);
compose.caretPosition = null;
compose.contentType = draft.default.contentType;
compose.spoilerText = '';
compose.approvalRequired = approvalRequired ?? false;
if (status.visibility === 'group') {
compose.groupId = status.group_id;
compose.visibility = 'group';
}
});
useModalsStore.getState().actions.openModal('COMPOSE');
},
mentionCompose: (account) => {
if (!lazyStore.getState().me) return;
get().actions.updateCompose('compose-modal', (compose) => {
compose.text = [compose.text.trim(), `@${account.acct} `]
.filter((str) => str.length !== 0)
.join(' ');
compose.caretPosition = null;
});
useModalsStore.getState().actions.openModal('COMPOSE');
},
directCompose: (account) => {
get().actions.updateCompose('compose-modal', (compose) => {
compose.text = [compose.text.trim(), `@${account.acct} `]
.filter((str) => str.length !== 0)
.join(' ');
compose.visibility = 'direct';
compose.caretPosition = null;
});
useModalsStore.getState().actions.openModal('COMPOSE');
},
groupComposeModal: (group) => {
const composeId = `group:${group.id}`;
get().actions.updateCompose(composeId, (draft) => {
draft.visibility = 'group';
draft.groupId = group.id;
draft.caretPosition = null;
});
useModalsStore.getState().actions.openModal('COMPOSE', { composeId });
},
openComposeWithText: (composeId, text = '') => {
set((state) => {
state.composers[composeId] = {
...state.default,
idempotencyKey: crypto.randomUUID(),
resetFileKey: getResetFileKey(),
...(composeId.startsWith('reply:') ? { inReplyToId: composeId.slice(6) } : undefined),
...(composeId.startsWith('group:')
? { visibility: 'group', groupId: composeId.slice(6) }
: undefined),
text,
};
});
useModalsStore.getState().actions.openModal('COMPOSE');
},
eventDiscussionCompose: (composeId, status) => {
const state = lazyStore.getState();
const account = selectOwnAccount(state);
if (!account) return;
get().actions.updateCompose(composeId, (compose) => {
compose.inReplyToId = status.id;
compose.to = statusToMentionsArray(status, account);
});
},
resetCompose: (composeId = 'compose-modal') => {
set((state) => {
state.composers[composeId] = {
...state.default,
idempotencyKey: crypto.randomUUID(),
resetFileKey: getResetFileKey(),
...(composeId.startsWith('reply:') ? { inReplyToId: composeId.slice(6) } : undefined),
...(composeId.startsWith('group:')
? { visibility: 'group', groupId: composeId.slice(6) }
: undefined),
};
});
},
selectComposeSuggestion: (composeId, position, token, suggestion, path) => {
let completion = '';
let startPosition = position;
if (typeof suggestion === 'object' && 'id' in suggestion) {
completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons;
startPosition = position - 1;
useSettingsStore.getState().actions.rememberEmojiUse(suggestion);
lazyStore.dispatch(saveSettings());
} else if (typeof suggestion === 'string' && suggestion[0] === '#') {
completion = suggestion;
startPosition = position - 1;
} else if (typeof suggestion === 'string') {
completion = selectAccount(suggestion)!.acct;
startPosition = position;
}
get().actions.updateCompose(composeId, (compose) => {
const updateText = (oldText?: string) =>
`${oldText?.slice(0, startPosition)}${completion} ${oldText?.slice(startPosition + (token?.length ?? 0))}`;
if (path[0] === 'spoiler_text') {
compose.spoilerText = updateText(compose.spoilerText);
} else if (compose.poll) {
compose.poll.options[path[2]] = updateText(compose.poll.options[path[2]]);
}
});
},
importDefaultSettings: (account) => {
get().actions.updateCompose('default', (compose) => {
const settings = account.settings_store?.[FE_NAME];
if (!settings) return;
if (settings.defaultPrivacy) compose.visibility = settings.defaultPrivacy;
if (settings.defaultContentType) compose.contentType = settings.defaultContentType;
});
},
importDefaultContentType: (instance) => {
get().actions.updateCompose('default', (compose) => {
const postFormats = instance.pleroma.metadata.post_formats;
compose.contentType =
postFormats.includes(compose.contentType) ||
(postFormats.includes('text/markdown') && compose.contentType === 'wysiwyg')
? compose.contentType
: postFormats.includes('text/markdown')
? 'text/markdown'
: postFormats[0];
});
},
handleTimelineDelete: (statusId) => {
get().actions.updateAllCompose((compose) => {
if (statusId === compose.inReplyToId) {
compose.inReplyToId = null;
}
if (statusId === compose.quoteId) {
compose.quoteId = null;
}
});
},
},
}),
{
enableAutoFreeze: false,
},
),
);
const useSubmitCompose = (composeId: string) => {
const actions = useComposeActions();
const client = useClient();
const dispatch = useAppDispatch();
const features = useFeatures();
const { openModal, closeModal } = useModalsActions();
const settings = useSettings();
const submitCompose = useCallback(
async (opts: { force?: boolean; preview?: boolean; onSuccess?: () => void } = {}) => {
const { force = false, preview = false, onSuccess } = opts;
const compose = actions.getCompose(composeId);
const statusText = compose.text;
const media = compose.mediaAttachments;
const editedId = compose.editedId;
let to = compose.to;
const { forceImplicitAddressing } = settings;
const explicitAddressing =
features.createStatusExplicitAddressing && !forceImplicitAddressing;
if (!preview) {
const scheduledAt = compose.scheduledAt;
if (scheduledAt) {
const fiveMinutesFromNow = new Date(Date.now() + 300000);
const valid =
scheduledAt.getTime() > fiveMinutesFromNow.getTime() ||
(features.scheduledStatusesBackwards && scheduledAt.getTime() < Date.now());
if (!valid) {
toast.error(messages.scheduleError);
return;
}
}
if ((!statusText || !statusText.length) && media.length === 0) {
return;
}
if (!force) {
const missingDescriptionModal = settings.missingDescriptionModal;
const hasMissing = media.some((item) => !item.description);
if (missingDescriptionModal && hasMissing) {
openModal('MISSING_DESCRIPTION', {
onContinue: () => {
closeModal('MISSING_DESCRIPTION');
submitCompose({ force: true, onSuccess });
},
});
return;
}
}
}
const mentionsMatch: string[] | null = statusText.match(
/(?:^|\s)@([a-z\d_-]+(?:@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]+)?)/gi,
);
if (mentionsMatch) {
to = [
...new Set([
...to,
...mentionsMatch.map((mention) => mention.replaceAll('&#x20;', '').trim().slice(1)),
]),
];
}
if (!preview) {
actions.updateCompose(composeId, (draft) => {
draft.isSubmitting = true;
});
closeModal('COMPOSE');
if (compose.language && !editedId) {
useSettingsStore.getState().actions.rememberLanguageUse(compose.language);
dispatch(saveSettings());
}
}
const idempotencyKey = compose.idempotencyKey;
const contentType = compose.contentType === 'wysiwyg' ? 'text/markdown' : compose.contentType;
const params: CreateStatusParams = {
status: statusText,
in_reply_to_id: compose.inReplyToId ?? undefined,
quote_id: compose.quoteId ?? undefined,
media_ids: media.map((item) => item.id),
sensitive: compose.sensitive,
spoiler_text: compose.spoilerText,
visibility: compose.visibility,
content_type: contentType,
scheduled_at: preview ? undefined : compose.scheduledAt?.toISOString(),
language: compose.language ?? compose.suggestedLanguage ?? undefined,
to: explicitAddressing && to.length ? to : undefined,
local_only: compose.localOnly,
interaction_policy:
(['public', 'unlisted', 'private'].includes(compose.visibility) &&
compose.interactionPolicy) ||
undefined,
quote_approval_policy: compose.quoteApprovalPolicy ?? undefined,
location_id: compose.location?.origin_id ?? undefined,
};
if (compose.editedId) {
// @ts-expect-error
params.media_attributes = media.map((item) => {
const focalPoint = (item.type === 'image' || item.type === 'gifv') && item.meta?.focus;
const focus = focalPoint
? `${focalPoint.x.toFixed(2)},${focalPoint.y.toFixed(2)}`
: undefined;
return { id: item.id, description: item.description, focus };
}) as EditStatusParams['media_attributes'];
}
if (compose.poll) {
params.poll = {
options: compose.poll.options,
expires_in: compose.poll.expires_in,
multiple: compose.poll.multiple,
hide_totals: compose.poll.hide_totals,
options_map: compose.poll.options_map,
};
}
if (compose.language && Object.keys(compose.textMap).length) {
params.status_map = compose.textMap;
params.status_map[compose.language] = statusText;
if (params.spoiler_text) {
params.spoiler_text_map = compose.spoilerTextMap;
params.spoiler_text_map[compose.language] = compose.spoilerText;
}
const pollParams = params.poll;
if (pollParams?.options_map) {
pollParams.options.forEach(
(option, index: number) => (pollParams.options_map![index][compose.language!] = option),
);
}
}
if (compose.visibility === 'group' && compose.groupId) {
params.group_id = compose.groupId;
}
if (preview) {
try {
const data = await client.statuses.previewStatus(params);
actions.updateCompose(composeId, (draft) => {
draft.preview = data;
});
onSuccess?.();
} catch {}
} else {
if (compose.redacting) {
// @ts-expect-error
params.overwrite = compose.redactingOverwrite;
}
try {
const data = await dispatch(
createStatus(params, idempotencyKey, editedId, compose.redacting),
);
const draftIdToCancel = compose.draftId;
actions.resetCompose(composeId);
if (draftIdToCancel) {
dispatch((_, getState) => {
const accountUrl = selectOwnAccount(getState())!.url;
cancelDraftStatus(queryClient, accountUrl, draftIdToCancel);
});
}
if (data.scheduled_at === null) {
const linkOptions: LinkOptions =
data.visibility === 'direct' && features.conversations
? { to: '/conversations' }
: {
to: '/@{$username}/posts/$statusId',
params: { username: data.account.acct, statusId: data.id },
};
toast.success(
compose.redacting
? messages.redactSuccess
: editedId
? messages.editSuccess
: messages.success,
{ actionLabel: messages.view, actionLinkOptions: linkOptions },
);
} else {
toast.success(messages.scheduledSuccess, {
actionLabel: messages.view,
actionLinkOptions: { to: '/scheduled_statuses' },
});
}
onSuccess?.();
} catch (error) {
actions.updateCompose(composeId, (draft) => {
draft.isSubmitting = false;
});
}
}
},
[composeId],
);
return submitCompose;
};
const useCompose = <ID extends string>(composeId: ID extends 'default' ? never : ID): Compose =>
useComposeStore((state) => state.composers[composeId] ?? state.default);
const useComposeActions = () => useComposeStore((state) => state.actions);
const useUploadCompose = (composeId: string) => {
const { updateCompose } = useComposeActions();
const instance = useInstance();
const dispatch = useAppDispatch();
const intl = useIntl();
return useCallback(
(files: FileList) => {
const compose =
useComposeStore.getState().composers[composeId] || useComposeStore.getState().default;
const attachmentLimit = instance.configuration.statuses.max_media_attachments;
const media = compose.mediaAttachments;
const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
const mediaCount = media ? media.length : 0;
if (files.length + mediaCount > attachmentLimit) {
toast.error(messages.uploadErrorLimit);
return;
}
updateCompose(composeId, (draft) => {
draft.isUploading = true;
});
Array.from(files).forEach((f, i) => {
if (mediaCount + i > attachmentLimit - 1) return;
dispatch(
uploadFile(
f,
intl,
(data) =>
updateCompose(composeId, (draft) => {
appendMedia(draft, data);
}),
() =>
updateCompose(composeId, (draft) => {
draft.isUploading = false;
}),
({ loaded }) => {
progress[i] = loaded;
updateCompose(composeId, (draft) => {
draft.progress = Math.round((progress.reduce((a, v) => a + v, 0) / total) * 100);
});
},
(value) => {
total += value;
},
),
);
});
},
[instance, composeId],
);
};
const useChangeUploadCompose = (composeId: string) => {
const { updateCompose } = useComposeActions();
const dispatch = useAppDispatch();
return useCallback(
async (mediaId: string, params: UpdateMediaParams) => {
const compose =
useComposeStore.getState().composers[composeId] || useComposeStore.getState().default;
updateCompose(composeId, (draft) => {
draft.isChangingUpload = true;
});
try {
const response = await dispatch(updateMedia(mediaId, params));
updateCompose(composeId, (draft) => {
draft.isChangingUpload = false;
draft.mediaAttachments = draft.mediaAttachments.map((item) =>
item.id === response.id ? response : item,
);
});
return response;
} catch (error: any) {
if (error.response?.status === 404 && compose.editedId) {
const previousMedia = compose.mediaAttachments.find((m) => m.id === mediaId);
if (previousMedia) {
updateCompose(composeId, (draft) => {
draft.isChangingUpload = false;
draft.mediaAttachments = draft.mediaAttachments.map((item) =>
item.id === mediaId ? { ...previousMedia, ...params } : item,
);
});
return;
}
}
updateCompose(composeId, (draft) => {
draft.isChangingUpload = false;
});
}
},
[composeId],
);
};
export {
type Compose,
appendMedia,
newPoll,
statusToMentionsAccountIdsArray,
useComposeStore,
useCompose,
useComposeActions,
useSubmitCompose,
useUploadCompose,
useChangeUploadCompose,
};