Composer redesign: Image and video attachments (#40171)

Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
Echo
2026-08-19 12:31:19 +00:00
committed by GitHub
parent a10bcae1f8
commit c2d8dbbc1b
22 changed files with 618 additions and 238 deletions

View File

@@ -147,6 +147,10 @@ export const changeUploadCompose = createDataLoadingThunk(
},
);
export const rearrangeComposeAttachments = createAction<string[]>(
'compose/rearrangeAttachments',
);
export const quoteCompose = createAppThunk(
'compose/quoteComposeStatus',
(status: Status, { dispatch }) => {

View File

@@ -6,28 +6,24 @@ import {
useState,
} from 'react';
import type { UniqueIdentifier } from '@dnd-kit/core';
import type { DragStartEvent, UniqueIdentifier } from '@dnd-kit/core';
import { useSortable } from '@dnd-kit/sortable';
import { normalizeKey } from '../hotkeys/utils';
interface UseSortableListArgs<Id extends UniqueIdentifier = string> {
ids: Id[];
onSort: (ids: Id[]) => void;
interface UseSortableListArgs {
onCancel: () => void;
}
export function useSortableList<Id extends UniqueIdentifier = string>({
onCancel,
}: UseSortableListArgs<Id>) {
const [isDragging, setIsDragging] = useState(false);
export function useSortableList({ onCancel }: UseSortableListArgs) {
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
const onDragStart = useCallback(() => {
setIsDragging(true);
const onDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id);
}, []);
const onDragEnd = useCallback(() => {
setIsDragging(false);
setActiveId(null);
}, []);
// Combines the Escape shortcut for closing the modal and for cancelling the drag, depending on the current state.
@@ -40,21 +36,20 @@ export function useSortableList<Id extends UniqueIdentifier = string>({
event.stopPropagation();
// Trigger the drag cancel here, since onDragCancel triggers before this handler.
if (isDragging) {
setIsDragging(false);
if (activeId) {
setActiveId(null);
} else {
onCancel();
}
}
},
[isDragging, onCancel],
[activeId, onCancel],
);
return {
isDragging,
activeId,
onDragStart,
onDragEnd,
onCancel,
onModalExit,
};
}

View File

@@ -16,6 +16,7 @@ interface SortableListItemOwnProps<As extends ValidItemElement> {
id: UniqueIdentifier;
draggingClassName?: string;
overClassName?: string;
noTransition?: boolean;
}
export type SortableListItemProps<As extends ValidItemElement> =
@@ -33,6 +34,7 @@ export const SortableListItem = <As extends ValidItemElement = 'li'>({
draggingClassName,
overClassName,
style: componentStyle,
noTransition,
...props
}: SortableListItemProps<As>) => {
const ItemComponent = AsComp ?? 'li';
@@ -56,7 +58,8 @@ export const SortableListItem = <As extends ValidItemElement = 'li'>({
const style = {
...componentStyle,
transform: CSS.Translate.toString(transform),
transition,
transition: noTransition ? undefined : transition,
['--transition']: transition,
};
return (
@@ -74,6 +77,8 @@ export const SortableListItem = <As extends ValidItemElement = 'li'>({
isDragging && draggingClassName,
isOver && overClassName,
)}
data-dragging={isDragging}
data-over={isOver}
>
<ItemHandleContext.Provider value={{ registerHandle, id }}>
{children}

View File

@@ -5,14 +5,18 @@ import type { MessageDescriptor } from 'react-intl';
import { useIntl } from 'react-intl';
import type {
Active,
Announcements,
DragEndEvent,
DragStartEvent,
DropAnimation,
Over,
ScreenReaderInstructions,
UniqueIdentifier,
} from '@dnd-kit/core';
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useSensor,
@@ -62,10 +66,13 @@ interface SortableListOwnProps<
ids: Id[];
renderItem?: (id: Id) => React.ReactNode;
messages?: SortableListMessages;
messageLabelCb?: (item: Active | Over) => Id;
as?: As;
onSort?: (ids: Id[]) => void;
onDragStart?: (event: DragStartEvent) => void;
onDragEnd?: (event: DragEndEvent) => void;
overlay?: React.ReactNode;
dropAnimation?: DropAnimation | null;
}
export type SortableListProps<
@@ -86,6 +93,9 @@ export const SortableList = <
onDragEnd: onDragEndParent,
messages,
children,
overlay,
dropAnimation,
messageLabelCb,
...props
}: SortableListProps<As, Id>) => {
const sensors = useSensors(
@@ -122,6 +132,9 @@ export const SortableList = <
if (!messages) {
return undefined;
}
const itemRender = messageLabelCb ?? (({ id }) => String(id));
return {
screenReaderInstructions: {
draggable: intl.formatMessage(messages.screenReaderInstructions),
@@ -133,15 +146,15 @@ export const SortableList = <
return undefined;
}
return intl.formatMessage(messages.onDragStart, {
item: active.id,
item: itemRender(active),
});
},
onDragOver({ active, over }) {
if (over && active.id !== over.id && messages.onDragMoveOver) {
return intl.formatMessage(messages.onDragMoveOver, {
item: active.id,
over: over.id,
item: itemRender(active),
over: itemRender(over),
});
}
@@ -150,30 +163,32 @@ export const SortableList = <
}
return intl.formatMessage(messages.onDragMove, {
item: active.id,
item: itemRender(active),
});
},
onDragEnd({ active }) {
onDragEnd({ active, over }) {
if (!messages.onDragEnd) {
return undefined;
}
return intl.formatMessage(messages.onDragEnd, {
item: active.id,
item: itemRender(active),
over: over ? itemRender(over) : undefined,
});
},
onDragCancel({ active }) {
onDragCancel({ active, over }) {
if (!messages.onDragCancel) {
return undefined;
}
return intl.formatMessage(messages.onDragCancel, {
item: active.id,
item: itemRender(active),
over: over ? itemRender(over) : undefined,
});
},
} satisfies Announcements,
};
}, [intl, messages]);
}, [intl, messageLabelCb, messages]);
const ListComponent = AsComp ?? 'ol';
@@ -196,6 +211,10 @@ export const SortableList = <
)))}
</SortableContext>
</ListComponent>
{overlay && (
<DragOverlay dropAnimation={dropAnimation}>{overlay}</DragOverlay>
)}
</DndContext>
);
};

View File

@@ -553,7 +553,7 @@ export const Audio: React.FC<{
/>
)}
<audio /* eslint-disable-line jsx-a11y/media-has-caption */
<audio
src={src}
ref={handleAudioRef}
preload={startPlaying ? 'auto' : 'none'}

View File

@@ -9,6 +9,7 @@ import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { selectAccountAvatarUrl } from '@/mastodon/selectors/accounts';
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import SoundIcon from '@/material-icons/400-24px/graphic_eq.svg?react';
@@ -18,19 +19,10 @@ import { openModal } from 'mastodon/actions/modal';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from 'mastodon/store';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { AudioVisualizer } from '../../audio/visualizer';
const selectUserAvatar = createAppSelector(
[(state) => state.accounts, (state) => state.meta.get('me') as string],
(accounts, myId) => accounts.get(myId)?.avatar_static,
);
export const Upload: React.FC<{
id: string;
dragging?: boolean;
@@ -50,7 +42,7 @@ export const Upload: React.FC<{
const sensitive = useAppSelector(
(state) => state.compose.get('spoiler') as boolean,
);
const userAvatar = useAppSelector(selectUserAvatar);
const userAvatar = useAppSelector(selectAccountAvatarUrl);
const handleUndoClick = useCallback(() => {
dispatch(undoUploadCompose(id));

View File

@@ -0,0 +1,167 @@
@use '@/styles/mastodon/mixins';
// Attachments
.mediaSingle .mediaUpload {
min-width: 120px;
max-width: min(100%, 400px);
max-height: var(--max-media-height-large);
}
.mediaGrid {
display: grid;
gap: var(--space-3xs);
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
aspect-ratio: 3/2;
&[data-number='2'] {
grid-template-rows: 1fr;
}
&[data-number='3'] .mediaUpload:first-child {
grid-row: span 2;
}
}
.mediaUpload {
border-radius: var(--radius-md);
position: relative;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
overflow: hidden;
}
.blurHash {
width: 100%;
height: 100%;
}
.mediaUploadPending {
animation: pulse 1s ease-in-out infinite;
background-color: var(--color-bg-disabled);
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
.mediaMenuButton {
position: absolute;
top: var(--space-xs);
right: var(--space-xs);
}
.mediaAlt {
@include mixins.type-micro;
display: block;
padding: var(--space-2xs);
border-radius: var(--radius-xs);
background: var(--color-bg-inverted);
color: var(--color-text-inverted);
position: absolute;
bottom: var(--space-xs);
right: var(--space-xs);
text-transform: uppercase;
}
.mediaMenuDelete {
color: var(--color-text-error);
}
// Audio
.audioWrapper {
--height: var(--space-5xl);
display: flex;
align-items: center;
border-radius: var(--radius-md);
gap: var(--space-2xs);
border: 0.5px solid var(--color-text-tertiary);
padding: var(--space-xs);
}
.audioCover {
height: var(--height);
width: auto;
aspect-ratio: 1;
border-radius: var(--radius-sm);
}
.audioControl {
overflow: hidden;
border-radius: var(--radius-sm);
display: block;
box-sizing: border-box;
flex-grow: 1;
height: var(--height);
}
// Polls
.poll {
display: flex;
flex-direction: column;
gap: var(--space-sm);
border: 0.5px solid var(--color-text-tertiary);
border-radius: var(--radius-md);
padding: var(--space-xs);
ol {
display: flex;
flex-direction: column;
gap: var(--space-xs);
counter-reset: poll;
width: 100%;
}
label {
@include mixins.type-label-sm;
}
}
.pollOption {
display: flex;
gap: var(--space-sm);
align-items: center;
counter-increment: poll;
&::before {
@include mixins.type-micro;
content: counter(poll, upper-alpha);
width: var(--space-lg);
height: var(--space-lg);
flex-shrink: 0;
line-height: var(--space-lg);
text-align: center;
border-radius: var(--radius-xs);
background: var(--color-bg-highlight);
}
}
.pollAddNew {
margin-left: var(--space-lg);
}
.pollMultipleToggle {
align-self: flex-start;
}
.pollControls {
display: flex;
justify-content: space-between;
}
.pollDurationSelect {
text-align: center;
}

View File

@@ -2,12 +2,12 @@ import type React from 'react';
import { useAppSelector } from '@/mastodon/store';
import classes from './attachments.module.scss';
import { ComposePoll } from './poll';
import {
selectComposeAttachments,
selectComposeHasAttachments,
} from './selectors';
import classes from './styles.module.scss';
import { ComposeUpload } from './upload';
export const ComposeAttachments: React.FC = () => {
@@ -31,14 +31,14 @@ export const ComposeAttachments: React.FC = () => {
const ComposeMediaAttachments: React.FC = () => {
const attachments = useAppSelector(selectComposeAttachments);
const pendingAttachments = useAppSelector((state) =>
Number(state.compose.get('pending_media_attachments')),
Math.max(Number(state.compose.get('pending_media_attachments')), 0),
);
const totalAttachments = attachments.length + pendingAttachments;
if (totalAttachments === 1) {
return (
<div className={classes.mediaSingle}>
<ComposeUpload id={attachments.at(0)?.id} />
<ComposeUpload id={attachments.at(0)?.id} single />
</div>
);
}

View File

@@ -118,7 +118,7 @@ const selectUpload = createAppSelector(
(state) => state.compose.get('resetFileKey') as number,
],
(
fileTypes,
fileTypesList,
isUploading,
attachments,
pendingAttachments,
@@ -129,8 +129,14 @@ const selectUpload = createAppSelector(
(attachment) =>
attachment.type === 'audio' || attachment.type === 'video',
);
const hasImages = attachments.some(
(attachment) => attachment.type === 'image' || attachment.type === 'gifv',
);
const fileTypes = (fileTypesList?.toArray() ?? []).filter(
(fileType) => !hasImages || fileType.startsWith('image/'),
);
return {
accepted: (fileTypes?.toArray() ?? []).join(','),
accepted: fileTypes.join(','),
loading: isUploading || pendingAttachments > 0,
disabled:
attachments.length + pendingAttachments >= maxAttachments ||

View File

@@ -13,7 +13,7 @@ import { useAppDispatch } from '@/mastodon/store';
import classes from './modals.module.scss';
const ComposerCancelConfirmModal: React.FC<{ openNew?: boolean }> = ({
const ComposerModalCancelConfirm: React.FC<{ openNew?: boolean }> = ({
openNew,
}) => {
const dispatch = useAppDispatch();
@@ -69,4 +69,4 @@ const ComposerCancelConfirmModal: React.FC<{ openNew?: boolean }> = ({
};
// eslint-disable-next-line import/no-default-export -- Modals import from default
export default ComposerCancelConfirmModal;
export default ComposerModalCancelConfirm;

View File

@@ -0,0 +1,191 @@
import type React from 'react';
import { useCallback, useState } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import type { UniqueIdentifier } from '@dnd-kit/core';
import { DotsSixVerticalIcon } from '@phosphor-icons/react';
import { rearrangeComposeAttachments } from '@/mastodon/actions/compose_typed';
import { Button, IconButton } from '@/mastodon/components/button/redesign';
import {
SortableList,
SortableListItem,
} from '@/mastodon/components/sortable_list';
import { useSortableList } from '@/mastodon/components/sortable_list/hooks';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import classes from './modals.module.scss';
import { selectComposeAttachment, selectComposeAttachments } from './selectors';
const messages = defineMessages({
screenReaderInstructions: {
id: 'compose.rearrange_modal.drag_instructions',
defaultMessage:
'To rearrange attachments, press space or enter. While dragging, use the arrow keys to move the attachment up or down. Press space or enter again to drop the attachment in its new position, or press escape to cancel.',
},
onDragStart: {
id: 'compose.rearrange_modal.drag_start',
defaultMessage: 'Picked up attachment at index {index, number}.',
},
onDragMove: {
id: 'compose.rearrange_modal.drag_move',
defaultMessage: 'Attachment index {index, number} was moved.',
},
onDragMoveOver: {
id: 'compose.rearrange_modal.drag_over',
defaultMessage:
'Attachment index {index, number} was moved over index {over, number}.',
},
onDragEnd: {
id: 'compose.rearrange_modal.drag_end',
defaultMessage:
'Attachment index {index, number} was moved to index {newIndex, number}.',
},
onDragCancel: {
id: 'compose.rearrange_modal.drag_cancel',
defaultMessage:
'Dragging was cancelled. Attachment index {index, number} was dropped.',
},
});
const ComposerModalRearrange: React.FC<{ onClose: () => void }> = ({
onClose,
}) => {
const attachments = useAppSelector(selectComposeAttachments);
const [attachmentIds, setAttachmentIds] = useState(() =>
attachments.map(({ id }) => id),
);
const { activeId, onDragStart, onDragEnd, onModalExit } = useSortableList({
onCancel: onClose,
});
const dispatch = useAppDispatch();
const handleSave = useCallback(() => {
dispatch(rearrangeComposeAttachments(attachmentIds));
onClose();
}, [attachmentIds, dispatch, onClose]);
const activeToIndex = useCallback(
(active: { id: UniqueIdentifier } | null) => {
if (!active) {
return '0';
}
return (attachmentIds.indexOf(String(active.id)) + 1).toString();
},
[attachmentIds],
);
return (
<div
className={classNames(classes.root, classes.attachmentRoot)}
onKeyUpCapture={onModalExit}
>
<h2 className={classes.title}>
<FormattedMessage
id='compose.rearrange_modal.title'
defaultMessage='Rearrange media'
/>
</h2>
<SortableList
ids={attachmentIds}
onSort={setAttachmentIds}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
messages={messages}
messageLabelCb={activeToIndex}
overlay={<ComposeOverlay activeId={activeId} ids={attachmentIds} />}
dropAnimation={null}
>
{attachmentIds.map((id) => (
<SortableListItem
id={id}
key={id}
noTransition
className={classes.attachmentItem}
draggingClassName={classes.attachmentGrabbed}
>
<ComposeRearrangeItemDisplay
id={id}
index={attachmentIds.indexOf(id)}
/>
</SortableListItem>
))}
</SortableList>
<div className={classes.footer}>
<Button onClick={onClose}>
<FormattedMessage
id='compose.rearrange_modal.cancel'
defaultMessage='Cancel'
/>
</Button>
<Button color='neutral' onClick={handleSave}>
<FormattedMessage
id='compose.rearrange_modal.save'
defaultMessage='Save'
/>
</Button>
</div>
</div>
);
};
const ComposeOverlay: React.FC<{
activeId: UniqueIdentifier | null;
ids: string[];
}> = ({ activeId, ids }) => (
<div
className={classNames(classes.attachmentItem, classes.attachmentOverlay)}
>
{typeof activeId === 'string' && (
<ComposeRearrangeItemDisplay
id={activeId}
index={ids.indexOf(activeId)}
aria-pressed
/>
)}
</div>
);
const ComposeRearrangeItemDisplay: React.FC<
{
id: string;
index: number;
} & Omit<React.ComponentPropsWithRef<'button'>, 'children' | 'id' | 'color'>
> = ({ id, index, className, ...props }) => {
const attachment = useAppSelector((state) =>
selectComposeAttachment(state, id),
);
return (
<>
<IconButton
{...props}
icon={DotsSixVerticalIcon}
className={classNames(className, classes.attachmentHandle)}
>
<FormattedMessage
id='compose.rearrange_modal.handle'
defaultMessage='Drag attachment at position {index, number}'
values={{ index: index + 1 }}
/>
</IconButton>
{attachment && (
<img
src={attachment.preview_url || attachment.url}
alt={attachment.description}
/>
)}
</>
);
};
// eslint-disable-next-line import/no-default-export -- Modals import from default
export default ComposerModalRearrange;

View File

@@ -23,3 +23,55 @@
align-items: center;
justify-content: space-between;
}
// Attachment rearrange modal
.attachmentRoot {
min-width: 400px;
padding: var(--space-md) 0;
.title,
.footer {
padding: 0 var(--space-md);
}
}
.attachmentItem {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-xs);
margin: 0 var(--space-xs);
transition:
opacity 200ms,
var(--transition, none);
border-bottom: 0.5px solid var(--color-border-primary);
&:focus {
outline: var(--outline-focus-default);
outline-offset: -2px;
}
img {
height: 60px;
width: 90px;
object-fit: cover;
border-radius: var(--radius-md);
}
}
.attachmentHandle {
padding: var(--space-xs) 0;
aspect-ratio: auto;
cursor: inherit;
}
.attachmentGrabbed > * {
opacity: 0;
}
.attachmentOverlay {
cursor: grabbing;
border-bottom: 0;
margin: 0;
}

View File

@@ -21,8 +21,8 @@ import {
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
import { DAY, HOUR, MINUTE } from '@/mastodon/utils/time';
import classes from './attachments.module.scss';
import { selectComposePoll } from './selectors';
import classes from './styles.module.scss';
const messages = defineMessages({
option_placeholder: {

View File

@@ -177,7 +177,9 @@ export const selectComposeHasAttachments = createAppSelector(
},
);
export type ComposeAttachment = ApiMediaAttachmentJSON & {
export type ComposeAttachment<
TAttachment extends ApiMediaAttachmentJSON = ApiMediaAttachmentJSON,
> = TAttachment & {
file?: File;
unattached: boolean;
};
@@ -197,6 +199,16 @@ export const selectComposeAttachments = createAppSelector(
},
);
export const selectComposeAttachment = createAppSelector(
[selectComposeAttachments, (_, id?: string) => id],
(attachments, id) => {
if (!id) {
return null;
}
return attachments.find((attachment) => attachment.id === id) ?? null;
},
);
export const selectComposePoll = createAppSelector(
[
(state) =>

View File

@@ -232,144 +232,6 @@
}
}
// Attachments
.mediaSingle {
overflow: auto;
.mediaUpload {
aspect-ratio: var(--width) / var(--height);
width: var(--width);
min-width: 120px;
max-width: 100%;
height: var(--height);
max-height: var(--max-media-height-large);
}
}
.mediaGrid {
display: grid;
gap: var(--space-3xs);
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
aspect-ratio: 3/2;
&[data-number='2'] {
grid-template-rows: 1fr;
}
&[data-number='3'] .mediaUpload:first-child {
grid-row: span 2;
}
}
.mediaUpload {
border-radius: var(--radius-md);
position: relative;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.mediaUploadPending {
animation: pulse 1s ease-in-out infinite;
background-color: var(--color-bg-disabled);
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
.mediaMenuButton {
position: absolute;
top: var(--space-xs);
right: var(--space-xs);
}
.mediaAlt {
@include mixins.type-micro;
display: block;
padding: var(--space-2xs);
border-radius: var(--radius-xs);
background: var(--color-bg-inverted);
color: var(--color-text-inverted);
position: absolute;
bottom: var(--space-xs);
right: var(--space-xs);
text-transform: uppercase;
}
.mediaMenuDelete {
color: var(--color-text-error);
}
// Polls
.poll {
display: flex;
flex-direction: column;
gap: var(--space-sm);
border: 0.5px solid var(--color-text-tertiary);
border-radius: var(--radius-md);
padding: var(--space-xs);
ol {
display: flex;
flex-direction: column;
gap: var(--space-xs);
counter-reset: poll;
width: 100%;
}
label {
@include mixins.type-label-sm;
}
}
.pollOption {
display: flex;
gap: var(--space-sm);
align-items: center;
counter-increment: poll;
&::before {
@include mixins.type-micro;
content: counter(poll, upper-alpha);
width: var(--space-lg);
height: var(--space-lg);
flex-shrink: 0;
line-height: var(--space-lg);
text-align: center;
border-radius: var(--radius-xs);
background: var(--color-bg-highlight);
}
}
.pollAddNew {
margin-left: var(--space-lg);
}
.pollMultipleToggle {
align-self: flex-start;
}
.pollControls {
display: flex;
justify-content: space-between;
}
.pollDurationSelect {
text-align: center;
}
// Emoji picker
div.emojiRoot {

View File

@@ -1,48 +1,37 @@
import type React from 'react';
import { useCallback, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import {
DotsThreeIcon,
PencilIcon,
PlusIcon,
TrashIcon,
} from '@phosphor-icons/react';
import { DotsThreeIcon, TrashIcon } from '@phosphor-icons/react';
import { undoUploadCompose } from '@/mastodon/actions/compose';
import { openModal } from '@/mastodon/actions/modal';
import type { ApiAudioAttachmentJSON } from '@/mastodon/api_types/media_attachments';
import { Blurhash } from '@/mastodon/components/blurhash';
import { IconButton } from '@/mastodon/components/button/redesign';
import {
DropdownItemButton,
DropdownPopover,
} from '@/mastodon/components/dropdown/redesign';
import { useToggle } from '@/mastodon/hooks/useToggle';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { selectComposeAttachments } from './selectors';
import classes from './styles.module.scss';
import classes from './attachments.module.scss';
import type { ComposeAttachment } from './selectors';
import { selectComposeAttachment } from './selectors';
const selectAttachment = createAppSelector(
[selectComposeAttachments, (_, id?: string) => id],
(attachments, id) => {
if (!id) {
return null;
}
return attachments.find((attachment) => attachment.id === id) ?? null;
},
);
export const ComposeUpload: React.FC<{ id?: string; className?: string }> = ({
id,
className,
}) => {
const attachment = useAppSelector((state) => selectAttachment(state, id));
export const ComposeUpload: React.FC<{
id?: string;
className?: string;
single?: boolean;
}> = ({ id, className, single }) => {
const attachment = useAppSelector((state) =>
selectComposeAttachment(state, id),
);
const sensitive = useAppSelector((state) => !!state.compose.get('spoiler'));
const [open, { onToggle, onFalse }] = useToggle();
const [target, setTarget] = useState<HTMLButtonElement | null>(null);
@@ -54,6 +43,10 @@ export const ComposeUpload: React.FC<{ id?: string; className?: string }> = ({
);
}
}, [dispatch, id]);
const handleRearrange = useCallback(() => {
onFalse();
dispatch(openModal({ modalType: 'COMPOSER_REARRANGE', modalProps: {} }));
}, [dispatch, onFalse]);
const handleDelete = useCallback(() => {
if (id) {
dispatch(undoUploadCompose(id));
@@ -64,36 +57,38 @@ export const ComposeUpload: React.FC<{ id?: string; className?: string }> = ({
return <div className={classNames(classes.mediaUpload, className)} />;
}
if (attachment.type === 'audio') {
return <ComposeAudioUpload attachment={attachment} />;
}
let x = 50;
let y = 50;
if (
attachment.type === 'image' ||
attachment.type === 'gifv' ||
attachment.type === 'video'
) {
const focusX = attachment.meta.focus?.x;
const focusY = attachment.meta.focus?.y;
if (focusX && focusY) {
x = (focusX / 2 + 0.5) * 100;
y = (focusY / -2 + 0.5) * 100;
}
const focusX = attachment.meta.focus?.x;
const focusY = attachment.meta.focus?.y;
if (focusX && focusY) {
x = (focusX / 2 + 0.5) * 100;
y = (focusY / -2 + 0.5) * 100;
}
return (
<div
className={classNames(classes.mediaUpload, className)}
style={
{
backgroundImage: attachment.preview_url
style={{
backgroundImage:
!sensitive && attachment.preview_url
? `url(${attachment.preview_url})`
: undefined,
backgroundPosition: `${x}% ${y}%`,
'--width': `${attachment.meta.original.width}px`,
'--height': `${attachment.meta.original.height}px`,
} as React.CSSProperties // Cast to allow properties
}
backgroundPosition: `${x}% ${y}%`,
aspectRatio: single
? `${attachment.meta.original.width} / ${attachment.meta.original.height}`
: undefined,
}}
data-color-scheme='dark'
>
{sensitive && attachment.blurhash && (
<Blurhash hash={attachment.blurhash} className={classes.blurHash} />
)}
<IconButton
icon={DotsThreeIcon}
size='sm'
@@ -116,10 +111,7 @@ export const ComposeUpload: React.FC<{ id?: string; className?: string }> = ({
offset={4}
maxWidth={170}
>
<DropdownItemButton
onClick={handleEdit}
leadingIcon={attachment.description ? PencilIcon : PlusIcon}
>
<DropdownItemButton onClick={handleEdit}>
{attachment.description ? (
<FormattedMessage
id='compose.upload.menu.edit_alt'
@@ -133,6 +125,15 @@ export const ComposeUpload: React.FC<{ id?: string; className?: string }> = ({
)}
</DropdownItemButton>
{!single && (
<DropdownItemButton onClick={handleRearrange}>
<FormattedMessage
id='compose.upload.menu.rearrange'
defaultMessage='Rearrange&hellip;'
/>
</DropdownItemButton>
)}
<hr />
<DropdownItemButton
@@ -155,3 +156,43 @@ export const ComposeUpload: React.FC<{ id?: string; className?: string }> = ({
</div>
);
};
const ComposeAudioUpload: React.FC<{
attachment: ComposeAttachment<ApiAudioAttachmentJSON>;
}> = ({ attachment }) => {
const { id, preview_url } = attachment;
const sensitive = useAppSelector((state) => !!state.compose.get('spoiler'));
const dispatch = useAppDispatch();
const handleDelete = useCallback(() => {
dispatch(undoUploadCompose(id));
}, [dispatch, id]);
return (
<div className={classes.audioWrapper}>
{!sensitive && preview_url && (
<img src={preview_url} alt='' className={classes.audioCover} />
)}
<audio
src={attachment.url}
controls
className={classes.audioControl}
controlsList='nodownload noplaybackrate'
/>
<IconButton
size='md'
variant='ghost'
icon={TrashIcon}
color='destructive'
onClick={handleDelete}
>
<FormattedMessage
id='compose.upload.audio.delete'
defaultMessage='Remove audio'
/>
</IconButton>
</div>
);
};

View File

@@ -106,7 +106,8 @@ export const MODAL_COMPONENTS = {
'ACCOUNT_EDIT_IMAGE_DELETE': accountEditModal('ImageDeleteModal'),
'ACCOUNT_EDIT_IMAGE_UPLOAD': accountEditModal('ImageUploadModal'),
'ACCOUNT_HIDE_FEATURED_TAB': () => import('@/mastodon/features/ui/components/confirmation_modals/hide_featured_tab').then(module => ({ default: module.ConfirmHideFeaturedTabModal })),
'COMPOSER_DRAFT_DELETE': () => import('@/mastodon/features/compose/redesign/cancel_modal'),
'COMPOSER_DRAFT_DELETE': () => import('@/mastodon/features/compose/redesign/modal_cancel'),
'COMPOSER_REARRANGE': () => import('@/mastodon/features/compose/redesign/modal_rearrange'),
};
/** @arg {keyof import('@/mastodon/features/account_edit/modals')} type */

View File

@@ -811,7 +811,7 @@ export const Video: React.FC<{
)}
{(revealed || editable) && (
<video /* eslint-disable-line jsx-a11y/media-has-caption */
<video
ref={handleVideoRef}
src={src}
poster={preview}

View File

@@ -522,15 +522,27 @@
"compose.published.body": "Post published.",
"compose.published.open": "Open",
"compose.quotable": "Allow others to quote",
"compose.rearrange_modal.cancel": "Cancel",
"compose.rearrange_modal.drag_cancel": "Dragging was cancelled. Attachment index {index, number} was dropped.",
"compose.rearrange_modal.drag_end": "Attachment index {index, number} was moved to index {newIndex, number}.",
"compose.rearrange_modal.drag_instructions": "To rearrange attachments, press space or enter. While dragging, use the arrow keys to move the attachment up or down. Press space or enter again to drop the attachment in its new position, or press escape to cancel.",
"compose.rearrange_modal.drag_move": "Attachment index {index, number} was moved.",
"compose.rearrange_modal.drag_over": "Attachment index {index, number} was moved over index {over, number}.",
"compose.rearrange_modal.drag_start": "Picked up attachment at index {index, number}.",
"compose.rearrange_modal.handle": "Drag attachment at position {index, number}",
"compose.rearrange_modal.save": "Save",
"compose.rearrange_modal.title": "Rearrange media",
"compose.reply.title.new": "New reply",
"compose.saved.body": "Post saved.",
"compose.sensitive": "Sensitive",
"compose.sensitive.text": "Sensitive content description",
"compose.upload.alt": "Alt",
"compose.upload.audio.delete": "Remove audio",
"compose.upload.menu": "Add alt text or remove the image",
"compose.upload.menu.add_alt": "Add alt text",
"compose.upload.menu.delete": "Remove image",
"compose.upload.menu.edit_alt": "Edit alt text",
"compose.upload.menu.rearrange": "Rearrange…",
"compose.visibility.quote_policy": "Who can quote",
"compose.visibility.quote_policy.anyone": "Anyone",
"compose.visibility.quote_policy.followers": "Followers",

View File

@@ -3,6 +3,7 @@ import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrde
import {
changeComposeVisibility,
changeUploadCompose,
rearrangeComposeAttachments,
quoteCompose,
quoteComposeCancel,
setComposeQuotePolicy,
@@ -350,6 +351,19 @@ export const composeReducer = (state = initialState, action) => {
return state.set('is_changing_upload', true);
} else if (changeUploadCompose.rejected.match(action)) {
return state.set('is_changing_upload', false);
} else if (rearrangeComposeAttachments.match(action)) {
return state.update('media_attachments', (attachments) => {
const newOrder = [];
for (const id of action.payload) {
const attachment = attachments.find((item) => item.get('id') === id);
if (attachment) {
newOrder.push(attachment);
}
}
return ImmutableList(newOrder);
});
} else if (quoteCompose.match(action)) {
const status = action.payload;
const isDirect = state.get('privacy') === 'direct';

View File

@@ -64,6 +64,11 @@ export const selectIsAccountLocal = createAppSelector(
(account) => !!account && account.username === account.acct,
);
export const selectAccountAvatarUrl = createAppSelector(
[(state) => state.accounts, (state) => state.meta.get('me') as string],
(accounts, myId) => accounts.get(myId)?.avatar_static,
);
export const getAccountHidden = createAppSelector(
[
(state, id: string) => state.accounts.get(id)?.hidden,

View File

@@ -303,6 +303,8 @@ export default tseslint.config([
'jsdoc/require-param': 'off',
'jsdoc/require-returns': 'off',
'jsx-a11y/media-has-caption': 'off',
'react/prefer-stateless-function': 'warn',
'react/function-component-definition': [
'error',