Add share dialog for collections (#37986)

This commit is contained in:
diondiondion authored and GitHub committed 2026-02-26 17:45:09 +00:00
1 parent bca57020a0
commit 7970eb392a
17 files changed
+498 -72

No files matched your search

@@ -44,6 +44,7 @@
--gap: 0.75ch;
display: flex;
flex-wrap: wrap;
gap: var(--gap);
& > li:not(:last-child)::after {
@@ -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}>
@@ -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;
}
}
@@ -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>
);
};
@@ -48,6 +48,10 @@
color: var(--color-text-secondary);
}
.previewAuthorNote {
font-size: 13px;
}
.metaData {
margin-top: 16px;
font-size: 15px;
@@ -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,
});
}
});
}
@@ -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>
);
};
@@ -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 }),
@@ -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');
}