diff --git a/Gemfile.lock b/Gemfile.lock
index b4d58cbb69..db436c01be 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -345,7 +345,7 @@ GEM
azure-blob (~> 0.5.2)
hashie (~> 5.0)
jmespath (1.6.2)
- json (2.13.2)
+ json (2.15.0)
json-canonicalization (1.0.0)
json-jwt (1.16.7)
activesupport (>= 4.2)
@@ -626,7 +626,7 @@ GEM
net-smtp
premailer (~> 1.7, >= 1.7.9)
prettyprint (0.2.0)
- prism (1.4.0)
+ prism (1.5.1)
prometheus_exporter (2.3.0)
webrick
propshaft (1.3.1)
@@ -722,7 +722,7 @@ GEM
redis (4.8.1)
redis-client (0.26.0)
connection_pool
- regexp_parser (2.11.2)
+ regexp_parser (2.11.3)
reline (0.6.2)
io-console (~> 0.5)
request_store (1.7.0)
@@ -766,7 +766,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.4)
- rubocop (1.80.2)
+ rubocop (1.81.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -774,10 +774,10 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
- rubocop-ast (>= 1.46.0, < 2.0)
+ rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
- rubocop-ast (1.46.0)
+ rubocop-ast (1.47.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-capybara (2.22.1)
@@ -901,9 +901,9 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.9.1)
- unicode-display_width (3.1.5)
- unicode-emoji (~> 4.0, >= 4.0.4)
- unicode-emoji (4.0.4)
+ unicode-display_width (3.2.0)
+ unicode-emoji (~> 4.1)
+ unicode-emoji (4.1.0)
uri (1.0.3)
useragent (0.16.11)
validate_url (1.0.15)
diff --git a/app/javascript/config/html-tags.json b/app/javascript/config/html-tags.json
new file mode 100644
index 0000000000..cf5c96540a
--- /dev/null
+++ b/app/javascript/config/html-tags.json
@@ -0,0 +1,78 @@
+{
+ "global": {
+ "class": "className",
+ "id": true,
+ "title": true,
+ "dir": true,
+ "lang": true
+ },
+ "tags": {
+ "p": {},
+ "br": {
+ "children": false
+ },
+ "span": {
+ "attributes": {
+ "translate": true
+ }
+ },
+ "a": {
+ "attributes": {
+ "href": true,
+ "rel": true,
+ "translate": true,
+ "target": true,
+ "title": true
+ }
+ },
+ "abbr": {
+ "attributes": {
+ "title": true
+ }
+ },
+ "del": {},
+ "s": {},
+ "pre": {},
+ "blockquote": {
+ "attributes": {
+ "cite": true
+ }
+ },
+ "code": {},
+ "b": {},
+ "strong": {},
+ "u": {},
+ "sub": {},
+ "sup": {},
+ "i": {},
+ "img": {
+ "children": false,
+ "attributes": {
+ "src": true,
+ "alt": true,
+ "title": true
+ }
+ },
+ "em": {},
+ "h1": {},
+ "h2": {},
+ "h3": {},
+ "h4": {},
+ "h5": {},
+ "ul": {},
+ "ol": {
+ "attributes": {
+ "start": true,
+ "reversed": true
+ }
+ },
+ "li": {
+ "attributes": {
+ "value": true
+ }
+ },
+ "ruby": {},
+ "rt": {},
+ "rp": {}
+ }
+}
diff --git a/app/javascript/flavours/glitch/components/follow_button.tsx b/app/javascript/flavours/glitch/components/follow_button.tsx
index e574b43b25..7e84a2580e 100644
--- a/app/javascript/flavours/glitch/components/follow_button.tsx
+++ b/app/javascript/flavours/glitch/components/follow_button.tsx
@@ -8,6 +8,8 @@ import { useIdentity } from '@/flavours/glitch/identity_context';
import {
fetchRelationships,
followAccount,
+ unblockAccount,
+ unmuteAccount,
} from 'flavours/glitch/actions/accounts';
import { openModal } from 'flavours/glitch/actions/modal';
import { Button } from 'flavours/glitch/components/button';
@@ -15,17 +17,49 @@ import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import { me } from 'flavours/glitch/initial_state';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
-const messages = defineMessages({
+import { useBreakpoint } from '../features/ui/hooks/useBreakpoint';
+
+const longMessages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
+ unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
+ followRequest: {
+ id: 'account.follow_request',
+ defaultMessage: 'Request to follow',
+ },
+ followRequestCancel: {
+ id: 'account.follow_request_cancel',
+ defaultMessage: 'Cancel request',
+ },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
});
+const shortMessages = {
+ ...longMessages, // Align type signature of shortMessages and longMessages
+ ...defineMessages({
+ followBack: {
+ id: 'account.follow_back_short',
+ defaultMessage: 'Follow back',
+ },
+ followRequest: {
+ id: 'account.follow_request_short',
+ defaultMessage: 'Request',
+ },
+ followRequestCancel: {
+ id: 'account.follow_request_cancel_short',
+ defaultMessage: 'Cancel',
+ },
+ editProfile: { id: 'account.edit_profile_short', defaultMessage: 'Edit' },
+ }),
+};
+
export const FollowButton: React.FC<{
accountId?: string;
compact?: boolean;
-}> = ({ accountId, compact }) => {
+ labelLength?: 'auto' | 'short' | 'long';
+}> = ({ accountId, compact, labelLength = 'auto' }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { signedIn } = useIdentity();
@@ -60,29 +94,48 @@ export const FollowButton: React.FC<{
if (accountId === me) {
return;
+ } else if (relationship.muting) {
+ dispatch(unmuteAccount(accountId));
} else if (account && (relationship.following || relationship.requested)) {
dispatch(
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
);
+ } else if (relationship.blocking) {
+ dispatch(unblockAccount(accountId));
} else {
dispatch(followAccount(accountId));
}
}, [dispatch, accountId, relationship, account, signedIn]);
+ const isNarrow = useBreakpoint('narrow');
+ const useShortLabel =
+ labelLength === 'short' || (labelLength === 'auto' && isNarrow);
+ const messages = useShortLabel ? shortMessages : longMessages;
+
+ const followMessage = account?.locked
+ ? messages.followRequest
+ : messages.follow;
+
let label;
if (!signedIn) {
- label = intl.formatMessage(messages.follow);
+ label = intl.formatMessage(followMessage);
} else if (accountId === me) {
label = intl.formatMessage(messages.edit_profile);
} else if (!relationship) {
label =
Hello, world!
\n\nThis should be filtered out:
', + }, + render(args) { + return ( + // Just for visual clarity in Storybook. +Hello, world!
\n\nThis should be filtered out:
', + }, + render(args) { + return ( + // Just for visual clarity in Storybook. ++ lorem ipsum +
, ] `; @@ -37,6 +39,7 @@ exports[`html > htmlStringToComponents > respects allowedTags option 1`] = ` [lorem + dolor diff --git a/app/javascript/mastodon/utils/__tests__/html-test.ts b/app/javascript/mastodon/utils/__tests__/html-test.ts index 6c08cc7cbf..6aacc396dc 100644 --- a/app/javascript/mastodon/utils/__tests__/html-test.ts +++ b/app/javascript/mastodon/utils/__tests__/html-test.ts @@ -48,7 +48,7 @@ describe('html', () => { const input = '
lorem ipsum
'; const onText = vi.fn((text: string) => text); html.htmlStringToComponents(input, { onText }); - expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum'); + expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum', {}); }); it('calls onElement callback', () => { @@ -61,6 +61,7 @@ describe('html', () => { expect(onElement).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ tagName: 'P' }), expect.arrayContaining(['lorem ipsum']), + {}, ); }); @@ -71,6 +72,7 @@ describe('html', () => { expect(onElement).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ tagName: 'P' }), expect.arrayContaining(['lorem ipsum']), + {}, ); expect(output).toMatchSnapshot(); }); @@ -88,15 +90,16 @@ describe('html', () => { 'href', 'https://example.com', 'a', + {}, ); - expect(onAttribute).toHaveBeenCalledWith('target', '_blank', 'a'); - expect(onAttribute).toHaveBeenCalledWith('rel', 'nofollow', 'a'); + expect(onAttribute).toHaveBeenCalledWith('target', '_blank', 'a', {}); + expect(onAttribute).toHaveBeenCalledWith('rel', 'nofollow', 'a', {}); }); it('respects allowedTags option', () => { const input = 'lorem ipsum dolor
'; const output = html.htmlStringToComponents(input, { - allowedTags: new Set(['p', 'em']), + allowedTags: { p: {}, em: {} }, }); expect(output).toMatchSnapshot(); }); diff --git a/app/javascript/mastodon/utils/html.ts b/app/javascript/mastodon/utils/html.ts index 1686322300..971aefa6d1 100644 --- a/app/javascript/mastodon/utils/html.ts +++ b/app/javascript/mastodon/utils/html.ts @@ -1,5 +1,7 @@ import React from 'react'; +import htmlConfig from '../../config/html-tags.json'; + // NB: This function can still return unsafe HTML export const unescapeHTML = (html: string) => { const wrapper = document.createElement('div'); @@ -10,64 +12,49 @@ export const unescapeHTML = (html: string) => { return wrapper.textContent; }; +interface AllowedTag { + /* True means allow, false disallows global attributes, string renames the attribute name for React. */ + attributes?: Record