diff --git a/app/javascript/mastodon/components/avatar.tsx b/app/javascript/mastodon/components/avatar.tsx index 6e1c5dbfd4..b086ef4225 100644 --- a/app/javascript/mastodon/components/avatar.tsx +++ b/app/javascript/mastodon/components/avatar.tsx @@ -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 @@ -91,3 +93,10 @@ export const Avatar: React.FC = ({ return avatar; }; + +export const AvatarById: React.FC< + { accountId: string } & Omit +> = ({ accountId, ...otherProps }) => { + const account = useAccount(accountId); + return ; +}; diff --git a/app/javascript/mastodon/components/copy_icon_button.tsx b/app/javascript/mastodon/components/copy_icon_button.tsx index 29f5f34430..51cffe6292 100644 --- a/app/javascript/mastodon/components/copy_icon_button.tsx +++ b/app/javascript/mastodon/components/copy_icon_button.tsx @@ -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} /> ); }; diff --git a/app/javascript/mastodon/components/form_fields/copy_link_field.module.scss b/app/javascript/mastodon/components/form_fields/copy_link_field.module.scss new file mode 100644 index 0000000000..06834e9d91 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/copy_link_field.module.scss @@ -0,0 +1,14 @@ +.wrapper { + position: relative; +} + +.input { + padding-inline-end: 45px; +} + +.copyButton { + position: absolute; + inset-inline-end: 0; + top: 0; + padding: 9px; +} diff --git a/app/javascript/mastodon/components/form_fields/copy_link_field.tsx b/app/javascript/mastodon/components/form_fields/copy_link_field.tsx new file mode 100644 index 0000000000..ad93e3a065 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/copy_link_field.tsx @@ -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( + ( + { id, label, hint, hasError, value, required, className, ...otherProps }, + ref, + ) => { + const intl = useIntl(); + const inputRef = useRef(); + 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 ( + + {(inputProps) => ( +
+ + +
+ )} +
+ ); + }, +); + +CopyLinkField.displayName = 'CopyLinkField'; diff --git a/app/javascript/mastodon/components/form_fields/index.ts b/app/javascript/mastodon/components/form_fields/index.ts index fca366106f..b44ceb63f8 100644 --- a/app/javascript/mastodon/components/form_fields/index.ts +++ b/app/javascript/mastodon/components/form_fields/index.ts @@ -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'; diff --git a/app/javascript/mastodon/components/modal_shell/index.tsx b/app/javascript/mastodon/components/modal_shell/index.tsx new file mode 100644 index 0000000000..8b6fdcc6ad --- /dev/null +++ b/app/javascript/mastodon/components/modal_shell/index.tsx @@ -0,0 +1,56 @@ +import classNames from 'classnames'; + +interface SimpleComponentProps { + className?: string; + children?: React.ReactNode; +} + +interface ModalShellComponent extends React.FC { + Body: React.FC; + Actions: React.FC; +} + +export const ModalShell: ModalShellComponent = ({ children, className }) => { + return ( +
+ {children} +
+ ); +}; + +const ModalShellBody: ModalShellComponent['Body'] = ({ + children, + className, +}) => { + return ( +
+
+ {children} +
+
+ ); +}; + +const ModalShellActions: ModalShellComponent['Actions'] = ({ + children, + className, +}) => { + return ( +
+
+ {children} +
+
+ ); +}; + +ModalShell.Body = ModalShellBody; +ModalShell.Actions = ModalShellActions; diff --git a/app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss b/app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss index 9e771dbaa0..3c71e90f48 100644 --- a/app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss +++ b/app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss @@ -44,6 +44,7 @@ --gap: 0.75ch; display: flex; + flex-wrap: wrap; gap: var(--gap); & > li:not(:last-child)::after { diff --git a/app/javascript/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx index d5b14da859..d2317e716f 100644 --- a/app/javascript/mastodon/features/collections/detail/index.tsx +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -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 = ( - + {previewMode ? ( + + ) : ( + + )} ); - if (id === me) { - return ( -

+ const displayAsYou = id === me && !previewMode; + + return ( +

+ {displayAsYou ? ( -

- ); - } - return ( -

- + ) : ( + + )}

); }; @@ -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 (
diff --git a/app/javascript/mastodon/features/collections/detail/share_modal.module.scss b/app/javascript/mastodon/features/collections/detail/share_modal.module.scss new file mode 100644 index 0000000000..2344ea519e --- /dev/null +++ b/app/javascript/mastodon/features/collections/detail/share_modal.module.scss @@ -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; + } +} diff --git a/app/javascript/mastodon/features/collections/detail/share_modal.tsx b/app/javascript/mastodon/features/collections/detail/share_modal.tsx new file mode 100644 index 0000000000..3bff066ee6 --- /dev/null +++ b/app/javascript/mastodon/features/collections/detail/share_modal.tsx @@ -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 ( + + +

+ {isNew ? ( + + ) : ( + + )} +

+ + + +
+
+

{collection.name}

+ +
+ + {collection.items.slice(0, 5).map(({ account_id }) => { + if (!account_id) return; + return ( + + ); + })} + +
+ + +
+ + +
+ + {'share' in navigator && ( + + )} +
+ + +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/collections/detail/styles.module.scss b/app/javascript/mastodon/features/collections/detail/styles.module.scss index cb94f2894c..690ec29f71 100644 --- a/app/javascript/mastodon/features/collections/detail/styles.module.scss +++ b/app/javascript/mastodon/features/collections/detail/styles.module.scss @@ -48,6 +48,10 @@ color: var(--color-text-secondary); } +.previewAuthorNote { + font-size: 13px; +} + .metaData { margin-top: 16px; font-size: 15px; diff --git a/app/javascript/mastodon/features/collections/editor/details.tsx b/app/javascript/mastodon/features/collections/editor/details.tsx index e8d99df4dd..6234bca514 100644 --- a/app/javascript/mastodon/features/collections/editor/details.tsx +++ b/app/javascript/mastodon/features/collections/editor/details.tsx @@ -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, + }); } }); } diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx index 2ea413208e..385ec6a794 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx @@ -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 ( -
-
-
-

{title}

- {message &&

{message}

} + + +

{title}

+ {message &&

{message}

} - {extraContent ?? children} -
-
+ {extraContent ?? children} + -
-
- - - {secondary && ( - <> -
- - + + - {/* eslint-disable jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */} - - {/* eslint-enable */} -
-
-
+ {secondary && ( + <> +
+ + + )} + + {/* eslint-disable jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */} + + {/* eslint-enable */} + + ); }; diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 7cacfab800..3e2751c7a3 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -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 }), diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index d6c0f70c70..099be340d2 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -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'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index e95ac60420..32088474eb 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 5a906c93a2..1e29fcb2b3 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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 {