Add share dialog for collections (#37986)

This commit is contained in:
diondiondion
2026-02-26 18:45:09 +01:00
committed by GitHub
parent bca57020a0
commit 7970eb392a
17 changed files with 498 additions and 72 deletions

View File

@@ -7,6 +7,8 @@ import { useHovering } from 'mastodon/hooks/useHovering';
import { autoPlayGif } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account';
import { useAccount } from '../hooks/useAccount';
interface Props {
account:
| Pick<Account, 'id' | 'acct' | 'avatar' | 'avatar_static'>
@@ -91,3 +93,10 @@ export const Avatar: React.FC<Props> = ({
return avatar;
};
export const AvatarById: React.FC<
{ accountId: string } & Omit<Props, 'account'>
> = ({ accountId, ...otherProps }) => {
const account = useAccount(accountId);
return <Avatar account={account} {...otherProps} />;
};

View File

@@ -19,8 +19,9 @@ const messages = defineMessages({
export const CopyIconButton: React.FC<{
title: string;
value: string;
className: string;
}> = ({ title, value, className }) => {
className?: string;
'aria-describedby'?: string;
}> = ({ title, value, className, 'aria-describedby': ariaDescribedBy }) => {
const [copied, setCopied] = useState(false);
const dispatch = useAppDispatch();
@@ -38,8 +39,9 @@ export const CopyIconButton: React.FC<{
className={classNames(className, copied ? 'copied' : 'copyable')}
title={title}
onClick={handleClick}
icon=''
icon='copy-icon'
iconComponent={ContentCopyIcon}
aria-describedby={ariaDescribedBy}
/>
);
};

View File

@@ -0,0 +1,14 @@
.wrapper {
position: relative;
}
.input {
padding-inline-end: 45px;
}
.copyButton {
position: absolute;
inset-inline-end: 0;
top: 0;
padding: 9px;
}

View File

@@ -0,0 +1,81 @@
import { forwardRef, useCallback, useRef } from 'react';
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
import classes from './copy_link_field.module.scss';
import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import { TextInput } from './text_input_field';
import type { TextInputProps } from './text_input_field';
interface CopyLinkFieldProps extends CommonFieldWrapperProps, TextInputProps {
value: string;
}
/**
* A read-only text field with a button for copying the field value
*/
export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
(
{ id, label, hint, hasError, value, required, className, ...otherProps },
ref,
) => {
const intl = useIntl();
const inputRef = useRef<HTMLInputElement | null>();
const handleFocus = useCallback(() => {
inputRef.current?.select();
}, []);
const mergeRefs = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
},
[ref],
);
return (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => (
<div className={classes.wrapper}>
<TextInput
readOnly
{...otherProps}
{...inputProps}
ref={mergeRefs}
value={value}
onFocus={handleFocus}
className={classNames(className, classes.input)}
/>
<CopyIconButton
value={value}
title={intl.formatMessage({
id: 'copy_icon_button.copy_this_text',
defaultMessage: 'Copy link to clipboard',
})}
className={classes.copyButton}
aria-describedby={inputProps.id}
/>
</div>
)}
</FormFieldWrapper>
);
},
);
CopyLinkField.displayName = 'CopyLinkField';

View File

@@ -1,3 +1,4 @@
export { FormFieldWrapper } from './form_field_wrapper';
export { FormStack } from './form_stack';
export { Fieldset } from './fieldset';
export { TextInputField, TextInput } from './text_input_field';
@@ -8,6 +9,7 @@ export {
Combobox,
type ComboboxItemState,
} from './combobox_field';
export { CopyLinkField } from './copy_link_field';
export { RadioButtonField, RadioButton } from './radio_button_field';
export { ToggleField, Toggle } from './toggle_field';
export { SelectField, Select } from './select_field';

View File

@@ -0,0 +1,56 @@
import classNames from 'classnames';
interface SimpleComponentProps {
className?: string;
children?: React.ReactNode;
}
interface ModalShellComponent extends React.FC<SimpleComponentProps> {
Body: React.FC<SimpleComponentProps>;
Actions: React.FC<SimpleComponentProps>;
}
export const ModalShell: ModalShellComponent = ({ children, className }) => {
return (
<div
className={classNames(
'modal-root__modal',
'safety-action-modal',
className,
)}
>
{children}
</div>
);
};
const ModalShellBody: ModalShellComponent['Body'] = ({
children,
className,
}) => {
return (
<div className='safety-action-modal__top'>
<div
className={classNames('safety-action-modal__confirmation', className)}
>
{children}
</div>
</div>
);
};
const ModalShellActions: ModalShellComponent['Actions'] = ({
children,
className,
}) => {
return (
<div className='safety-action-modal__bottom'>
<div className={classNames('safety-action-modal__actions', className)}>
{children}
</div>
</div>
);
};
ModalShell.Body = ModalShellBody;
ModalShell.Actions = ModalShellActions;

View File

@@ -44,6 +44,7 @@
--gap: 0.75ch;
display: flex;
flex-wrap: wrap;
gap: var(--gap);
& > li:not(:last-child)::after {

View File

@@ -3,18 +3,21 @@ import { useCallback, useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router';
import { useLocation, useParams } from 'react-router';
import { openModal } from '@/mastodon/actions/modal';
import { useRelationship } from '@/mastodon/hooks/useRelationship';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { showAlert } from 'mastodon/actions/alerts';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Account } from 'mastodon/components/account';
import { Avatar } from 'mastodon/components/avatar';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LinkedDisplayName } from 'mastodon/components/display_name';
import {
DisplayName,
LinkedDisplayName,
} from 'mastodon/components/display_name';
import { IconButton } from 'mastodon/components/icon_button';
import ScrollableList from 'mastodon/components/scrollable_list';
import { Tag } from 'mastodon/components/tags/tag';
@@ -46,32 +49,40 @@ const messages = defineMessages({
},
});
const AuthorNote: React.FC<{ id: string }> = ({ id }) => {
export const AuthorNote: React.FC<{ id: string; previewMode?: boolean }> = ({
id,
// When previewMode is enabled, your own display name
// will not be replaced with "you"
previewMode = false,
}) => {
const account = useAccount(id);
const author = (
<span className={classes.displayNameWithAvatar}>
<Avatar size={18} account={account} />
<LinkedDisplayName displayProps={{ account, variant: 'simple' }} />
{previewMode ? (
<DisplayName account={account} variant='simple' />
) : (
<LinkedDisplayName displayProps={{ account, variant: 'simple' }} />
)}
</span>
);
if (id === me) {
return (
<p className={classes.authorNote}>
const displayAsYou = id === me && !previewMode;
return (
<p className={previewMode ? classes.previewAuthorNote : classes.authorNote}>
{displayAsYou ? (
<FormattedMessage
id='collections.detail.curated_by_you'
defaultMessage='Curated by you'
/>
</p>
);
}
return (
<p className={classes.authorNote}>
<FormattedMessage
id='collections.detail.curated_by_author'
defaultMessage='Curated by {author}'
values={{ author }}
/>
) : (
<FormattedMessage
id='collections.detail.curated_by_author'
defaultMessage='Curated by {author}'
values={{ author }}
/>
)}
</p>
);
};
@@ -84,8 +95,23 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
const dispatch = useAppDispatch();
const handleShare = useCallback(() => {
dispatch(showAlert({ message: 'Collection sharing not yet implemented' }));
}, [dispatch]);
dispatch(
openModal({
modalType: 'SHARE_COLLECTION',
modalProps: {
collection,
},
}),
);
}, [collection, dispatch]);
const location = useLocation<{ newCollection?: boolean }>();
const wasJustCreated = location.state.newCollection;
useEffect(() => {
if (wasJustCreated) {
handleShare();
}
}, [handleShare, wasJustCreated]);
return (
<div className={classes.header}>

View File

@@ -0,0 +1,75 @@
.heading {
font-size: 28px;
line-height: 1.3;
margin-bottom: 16px;
}
.preview {
display: flex;
flex-wrap: wrap-reverse;
align-items: start;
justify-content: space-between;
gap: 8px;
padding: 16px;
margin-bottom: 16px;
border-radius: 8px;
color: var(--color-text-primary);
background: linear-gradient(
145deg,
var(--color-bg-brand-soft),
var(--color-bg-primary)
);
border: 1px solid var(--color-bg-brand-base);
}
.previewHeading {
font-size: 22px;
line-height: 1.3;
margin-bottom: 4px;
}
.actions {
display: flex;
flex-direction: column;
justify-content: center;
}
$bottomsheet-breakpoint: 630px;
.shareButtonWrapper {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
width: 100%;
& > button {
flex: 1;
min-width: 220px;
white-space: normal;
@media (width > $bottomsheet-breakpoint) {
max-width: 50%;
}
}
}
.closeButtonDesktop {
position: absolute;
top: 4px;
inset-inline-end: 4px;
padding: 8px;
@media (width <= $bottomsheet-breakpoint) {
display: none;
}
}
.closeButtonMobile {
margin-top: 16px;
margin-bottom: -18px;
@media (width > $bottomsheet-breakpoint) {
display: none;
}
}

View File

@@ -0,0 +1,141 @@
import { useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useLocation } from 'react-router';
import { me } from '@/mastodon/initial_state';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { changeCompose, focusCompose } from 'mastodon/actions/compose';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { AvatarById } from 'mastodon/components/avatar';
import { AvatarGroup } from 'mastodon/components/avatar_group';
import { Button } from 'mastodon/components/button';
import { CopyLinkField } from 'mastodon/components/form_fields';
import { IconButton } from 'mastodon/components/icon_button';
import { ModalShell } from 'mastodon/components/modal_shell';
import { useAppDispatch } from 'mastodon/store';
import { AuthorNote } from '.';
import classes from './share_modal.module.scss';
const messages = defineMessages({
shareTextOwn: {
id: 'collection.share_template_own',
defaultMessage: 'Check out my new collection: {link}',
},
shareTextOther: {
id: 'collection.share_template_other',
defaultMessage: 'Check out this cool collection: {link}',
},
});
export const CollectionShareModal: React.FC<{
collection: ApiCollectionJSON;
onClose: () => void;
}> = ({ collection, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const location = useLocation<{ newCollection?: boolean }>();
const isNew = !!location.state.newCollection;
const isOwnCollection = collection.account_id === me;
const collectionLink = `${window.location.origin}/collections/${collection.id}`;
const handleShareOnDevice = useCallback(() => {
void navigator.share({
url: collectionLink,
});
}, [collectionLink]);
const handleShareViaPost = useCallback(() => {
const shareMessage = isOwnCollection
? intl.formatMessage(messages.shareTextOwn, {
link: collectionLink,
})
: intl.formatMessage(messages.shareTextOther, {
link: collectionLink,
});
onClose();
dispatch(changeCompose(shareMessage));
dispatch(focusCompose());
}, [collectionLink, dispatch, intl, isOwnCollection, onClose]);
return (
<ModalShell>
<ModalShell.Body>
<h1 className={classes.heading}>
{isNew ? (
<FormattedMessage
id='collection.share_modal.title_new'
defaultMessage='Share your new collection!'
/>
) : (
<FormattedMessage
id='collection.share_modal.title'
defaultMessage='Share collection'
/>
)}
</h1>
<IconButton
title={intl.formatMessage({
id: 'lightbox.close',
defaultMessage: 'Close',
})}
iconComponent={CloseIcon}
icon='close'
className={classes.closeButtonDesktop}
onClick={onClose}
/>
<div className={classes.preview}>
<div>
<h2 className={classes.previewHeading}>{collection.name}</h2>
<AuthorNote previewMode id={collection.account_id} />
</div>
<AvatarGroup>
{collection.items.slice(0, 5).map(({ account_id }) => {
if (!account_id) return;
return (
<AvatarById key={account_id} accountId={account_id} size={28} />
);
})}
</AvatarGroup>
</div>
<CopyLinkField
label={intl.formatMessage({
id: 'collection.share_modal.share_link_label',
defaultMessage: 'Invite share link',
})}
value={collectionLink}
/>
</ModalShell.Body>
<ModalShell.Actions className={classes.actions}>
<div className={classes.shareButtonWrapper}>
<Button secondary onClick={handleShareViaPost}>
<FormattedMessage
id='collection.share_modal.share_via_post'
defaultMessage='Post on Mastodon'
/>
</Button>
{'share' in navigator && (
<Button secondary onClick={handleShareOnDevice}>
<FormattedMessage
id='collection.share_modal.share_via_system'
defaultMessage='Share to…'
/>
</Button>
)}
</div>
<Button plain onClick={onClose} className={classes.closeButtonMobile}>
<FormattedMessage id='lightbox.close' defaultMessage='Close' />
</Button>
</ModalShell.Actions>
</ModalShell>
);
};

View File

@@ -48,6 +48,10 @@
color: var(--color-text-secondary);
}
.previewAuthorNote {
font-size: 13px;
}
.metaData {
margin-top: 16px;
font-size: 15px;

View File

@@ -127,7 +127,9 @@ export const CollectionDetails: React.FC<{
history.replace(
`/collections/${result.payload.collection.id}/edit/details`,
);
history.push(`/collections/${result.payload.collection.id}`);
history.push(`/collections/${result.payload.collection.id}`, {
newCollection: true,
});
}
});
}

View File

@@ -3,6 +3,7 @@ import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { Button } from 'mastodon/components/button';
import { ModalShell } from 'mastodon/components/modal_shell';
export interface BaseConfirmationModalProps {
onClose: () => void;
@@ -56,53 +57,49 @@ export const ConfirmationModal: React.FC<
}, [onClose, onSecondary]);
return (
<div className='modal-root__modal safety-action-modal'>
<div className='safety-action-modal__top'>
<div className='safety-action-modal__confirmation'>
<h1 id={titleId}>{title}</h1>
{message && <p>{message}</p>}
<ModalShell>
<ModalShell.Body>
<h1 id={titleId}>{title}</h1>
{message && <p>{message}</p>}
{extraContent ?? children}
</div>
</div>
{extraContent ?? children}
</ModalShell.Body>
<div className='safety-action-modal__bottom'>
<div className='safety-action-modal__actions'>
<button onClick={onClose} className='link-button' type='button'>
{cancel ?? (
<FormattedMessage
id='confirmation_modal.cancel'
defaultMessage='Cancel'
/>
)}
</button>
{secondary && (
<>
<div className='spacer' />
<button
onClick={handleSecondary}
className='link-button'
type='button'
disabled={disabled}
>
{secondary}
</button>
</>
<ModalShell.Actions>
<button onClick={onClose} className='link-button' type='button'>
{cancel ?? (
<FormattedMessage
id='confirmation_modal.cancel'
defaultMessage='Cancel'
/>
)}
</button>
{/* eslint-disable jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */}
<Button
onClick={handleClick}
loading={updating}
disabled={disabled}
autoFocus={!noFocusButton}
>
{confirm}
</Button>
{/* eslint-enable */}
</div>
</div>
</div>
{secondary && (
<>
<div className='spacer' />
<button
onClick={handleSecondary}
className='link-button'
type='button'
disabled={disabled}
>
{secondary}
</button>
</>
)}
{/* eslint-disable jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */}
<Button
onClick={handleClick}
loading={updating}
disabled={disabled}
autoFocus={!noFocusButton}
>
{confirm}
</Button>
{/* eslint-enable */}
</ModalShell.Actions>
</ModalShell>
);
};

View File

@@ -11,6 +11,7 @@ import {
DomainBlockModal,
ReportModal,
ReportCollectionModal,
ShareCollectionModal,
EmbedModal,
ListAdder,
CompareHistoryModal,
@@ -79,6 +80,7 @@ export const MODAL_COMPONENTS = {
'DOMAIN_BLOCK': DomainBlockModal,
'REPORT': ReportModal,
'REPORT_COLLECTION': ReportCollectionModal,
'SHARE_COLLECTION': ShareCollectionModal,
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal,
'FOCAL_POINT': () => Promise.resolve({ default: AltTextModal }),

View File

@@ -62,6 +62,12 @@ export function CollectionsEditor() {
);
}
export function ShareCollectionModal() {
return import('../../collections/detail/share_modal').then(
module => ({default: module.CollectionShareModal})
);
}
export function Status () {
return import('../../status');
}

View File

@@ -271,6 +271,13 @@
"closed_registrations_modal.find_another_server": "Find another server",
"closed_registrations_modal.preamble": "Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!",
"closed_registrations_modal.title": "Signing up on Mastodon",
"collection.share_modal.share_link_label": "Invite share link",
"collection.share_modal.share_via_post": "Post on Mastodon",
"collection.share_modal.share_via_system": "Share to…",
"collection.share_modal.title": "Share collection",
"collection.share_modal.title_new": "Share your new collection!",
"collection.share_template_other": "Check out this cool collection: {link}",
"collection.share_template_own": "Check out my new collection: {link}",
"collections.account_count": "{count, plural, one {# account} other {# accounts}}",
"collections.accounts.empty_description": "Add up to {count} accounts you follow",
"collections.accounts.empty_title": "This collection is empty",
@@ -448,6 +455,7 @@
"conversation.open": "View conversation",
"conversation.with": "With {names}",
"copy_icon_button.copied": "Copied to clipboard",
"copy_icon_button.copy_this_text": "Copy link to clipboard",
"copypaste.copied": "Copied",
"copypaste.copy_to_clipboard": "Copy to clipboard",
"directory.federated": "From known fediverse",

View File

@@ -6408,15 +6408,15 @@ a.status-card {
line-height: 20px;
color: var(--color-text-secondary);
h1 {
:where(h1) {
font-size: 16px;
line-height: 24px;
color: var(--color-text-primary);
font-weight: 500;
}
&:not(:only-child) {
margin-bottom: 8px;
}
:where(h1:not(:only-child)) {
margin-bottom: 8px;
}
strong {