diff --git a/app/javascript/mastodon/components/copy_icon_button.jsx b/app/javascript/mastodon/components/copy_icon_button.jsx new file mode 100644 index 00000000000000..9b1a36d83ab0da --- /dev/null +++ b/app/javascript/mastodon/components/copy_icon_button.jsx @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import { useState, useCallback } from 'react'; + +import { defineMessages } from 'react-intl'; + +import classNames from 'classnames'; + +import { useDispatch } from 'react-redux'; + +import { ReactComponent as ContentCopyIcon } from '@material-symbols/svg-600/outlined/content_copy.svg'; + +import { showAlert } from 'mastodon/actions/alerts'; +import { IconButton } from 'mastodon/components/icon_button'; + +const messages = defineMessages({ + copied: { id: 'copy_icon_button.copied', defaultMessage: 'Copied to clipboard' }, +}); + +export const CopyIconButton = ({ title, value, className }) => { + const [copied, setCopied] = useState(false); + const dispatch = useDispatch(); + + const handleClick = useCallback(() => { + navigator.clipboard.writeText(value); + setCopied(true); + dispatch(showAlert({ message: messages.copied })); + setTimeout(() => setCopied(false), 700); + }, [setCopied, value, dispatch]); + + return ( + + ); +}; + +CopyIconButton.propTypes = { + title: PropTypes.string, + value: PropTypes.string, + className: PropTypes.string, +}; diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx index 7594135a4ea7b8..29b46cb43d4b5a 100644 --- a/app/javascript/mastodon/features/account/components/header.jsx +++ b/app/javascript/mastodon/features/account/components/header.jsx @@ -14,10 +14,12 @@ import { ReactComponent as LockIcon } from '@material-symbols/svg-600/outlined/l import { ReactComponent as MoreHorizIcon } from '@material-symbols/svg-600/outlined/more_horiz.svg'; import { ReactComponent as NotificationsIcon } from '@material-symbols/svg-600/outlined/notifications.svg'; import { ReactComponent as NotificationsActiveIcon } from '@material-symbols/svg-600/outlined/notifications_active-fill.svg'; +import { ReactComponent as ShareIcon } from '@material-symbols/svg-600/outlined/share.svg'; import { Avatar } from 'mastodon/components/avatar'; import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge'; import { Button } from 'mastodon/components/button'; +import { CopyIconButton } from 'mastodon/components/copy_icon_button'; import { FollowersCounter, FollowingCounter, StatusesCounter } from 'mastodon/components/counters'; import { Icon } from 'mastodon/components/icon'; import { IconButton } from 'mastodon/components/icon_button'; @@ -46,6 +48,7 @@ const messages = defineMessages({ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, report: { id: 'account.report', defaultMessage: 'Report @{name}' }, share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' }, + copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' }, media: { id: 'account.media', defaultMessage: 'Media' }, blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, @@ -245,11 +248,10 @@ class Header extends ImmutablePureComponent { const isRemote = account.get('acct') !== account.get('username'); const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null; - let info = []; - let actionBtn = ''; - let bellBtn = ''; - let lockedIcon = ''; - let menu = []; + let actionBtn, bellBtn, lockedIcon, shareBtn; + + let info = []; + let menu = []; if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { info.push(); @@ -267,6 +269,12 @@ class Header extends ImmutablePureComponent { bellBtn = ; } + if ('share' in navigator) { + shareBtn = ; + } else { + shareBtn = ; + } + if (me !== account.get('id')) { if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded actionBtn = ''; @@ -297,10 +305,6 @@ class Header extends ImmutablePureComponent { if (isRemote) { menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: account.get('url') }); - } - - if ('share' in navigator && !account.get('suspended')) { - menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); menu.push(null); } @@ -414,6 +418,7 @@ class Header extends ImmutablePureComponent { <> {actionBtn} {bellBtn} + {shareBtn} )} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 041446037586cb..16941e2ca4c14a 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -21,6 +21,7 @@ "account.blocked": "Blocked", "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow", + "account.copy": "Copy link to profile", "account.direct": "Privately mention @{name}", "account.disable_notifications": "Stop notifying me when @{name} posts", "account.domain_blocked": "Domain blocked", @@ -191,6 +192,7 @@ "conversation.mark_as_read": "Mark as read", "conversation.open": "View conversation", "conversation.with": "With {names}", + "copy_icon_button.copied": "Copied 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 9f87352f547b36..cc9b54d9e5b317 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -286,6 +286,17 @@ font-size: 12px; font-weight: 500; } + + &.copyable { + transition: all 300ms linear; + } + + &.copied { + border-color: $valid-value-color; + color: $valid-value-color; + transition: none; + background-color: rgba($valid-value-color, 0.15); + } } .text-icon-button { @@ -7373,6 +7384,16 @@ noscript { width: 24px; height: 24px; } + + &.copied { + border-color: $valid-value-color; + } + } + + @media screen and (width <= 427px) { + .optional { + display: none; + } } }