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;
+ }
}
}